Author SHA1 Message Date
SenrokaiandClaude Opus 5.5 0a88797ff4 Keep the dock's tabs in sight around the date strip, on phones and narrow desktop windows
67a21b4 let the dock's tabs give way to the date and range strips and
scroll, and measured phones only after Go. Reviewers found four things it
did not measure:
- At the present, the state the map opens in, the Range strip, now 121 px
  and unshrinkable, took the tabs' width on a portrait phone: Display showed
  0 of 79 px at 360, 390 and 412 wide, and Bookmarks 0, 27 and 49 of 95.
  Before, the strip sat off screen at x 406-502. It is now hidden below sm.
- After Go on a phone, focus went back to the Display tab while the list
  was still wide; the strip then narrowed it and its scroll stayed, so the
  focused tab was 0 of 79 px in sight (a focus ring off screen), and on
  Saturn's page the Clock tab 28 of 63. The focused tab, else the selected
  one, is now scrolled into view after each render in which the strip comes
  or goes (afterRenderEffect on whether a date is shown).
- On desktop windows 600 to 770 px wide the tab list drew the browser's
  classic scrollbar, light and 15 px tall, in the dark dock, and at 640 the
  open Display panel's tab was scrolled out of sight. The list now asks for
  a thin dark one (scheme-dark, scrollbar-width: thin); the tab is brought
  back as above.
- The range strip's shrink-0 had no test: without it '417 AU' wraps and the
  row grows from 38 to 58 px.

Measured on :4301 in the Sun's system, keyboard only (Tab to Display,
Enter, Tab to the date, 2020-12-21T18:00, Enter), visible px of each tab:
- 360x640 phone, at the present: Bookmarks 95/95, Display 24/79 (was 0),
  range hidden. After Go: the focused Display 78/79 (was 0), the date on
  screen, no page scroll. 390x844: 54/79 then 78/79. 412x915: 76/79 then
  78/79.
- Saturn's page at 360x640, after Go: the focused Clock tab 63/63.
- 600x800 desktop: every tab in full before and after Go, no scrollbar.
- 640x900, 700x900, 768x1024 desktop, after Go: the open Display tab 87/87,
  a dark scrollbar 10 px tall (row 43 px, was 48 with a light one).
- 1400x900: unchanged, row 38 px.
With no strip at all, a phone's row is now 33 px tall at the present and
38 once a date is set; the off-screen range strip used to hold it at 38.

Tests: 'keeps the date strip on screen on a phone' now also sets a range
and expects it shrink-0 and max-sm:hidden, and the list scheme-dark and a
thin scrollbar; 'brings the tab that matters back into view once the date
strip has narrowed the tabs' checks the focused tab on a phone and the
selected tab on a wide screen. Guarded mutants, each failing its named test
alone: the range's shrink-0 dropped, the range shown on a phone, the
default scrollbar, no tab scrolled into view, the selected tab only, and
the effect not keyed on the strip. Unit suite 870 passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 19:44:59 +02:00
SenrokaiandClaude Opus 5.5 4951fd8657 Test that a body page opens the next body on its Sun's side and turns an exoplanet for show
Two behaviours of the body page had no test. Showing a body resets the
remembered side of the equator (sunSide = 0), so the next body opens on its
own Sun's side wherever the reader left the camera; the only test that
switched bodies went from Saturn's June Sun (south) to Earth's (north),
which moves the camera with or without the reset. And the page turns an
exoplanet 0.08 radians a second for show, which no test opened.

- 'opens the next body shown on its own Sun's side, wherever the reader
  left the camera: Earth after Saturn in December': Saturn on 2032-12-01,
  the camera taken north, then Earth, whose December Sun is south too.
  Guarded mutant, the reset replaced by void 0: this test fails alone,
  "expected 0.5999999999999999 to be less than 0".
- 'turns an exoplanet slowly for show, clock or no clock': the fake loader
  now carries one exoplanet round the Sun's record; one second with the
  clock standing turns it 0.08 radians. Guarded mutant, the turn replaced
  by void deltaSeconds: this test fails alone.

The template comment said no one has measured an exoplanet's day. The app
ships two whose spin has been: 2M1207 b turns in 10.7 +1.2/-0.6 hours
(Zhou et al. 2016, ApJ 818, 176) and beta Pic b's lines are broadened by
25 +/- 3 km/s (Snellen et al. 2014, Nature 509, 63). It now says the
catalogue does not carry the day, which is what ExoplanetRecord holds.
Unit suite 869 passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 19:34:15 +02:00
SenrokaiandClaude Opus 5.5 d5a5a0fc3a Move a body page's camera across the equator only once the Sun is 3 degrees past it
a9e910c mirrored the camera whenever the Sun's height above the body's
equator changed sign. The side is chosen for Saturn's rings, whose Sun goes
26.7 degrees either side, but the rule held for every body: Mercury's Sun,
never more than 0.034 degrees off its equator, crosses it 8.3 times a year,
so at a month a second the camera went from one side to the other every
1.45 seconds, mirroring wherever the reader had orbited it, with both sides
lit alike. Venus's Sun reaches 2.6 degrees and the Moon's 1.6.

The side is still chosen whenever a body is shown, but afterwards it changes
only when the Sun is more than 3 degrees across (SUN_SIDE_MIN_SINE, on the
sine of its latitude). Measured in the app on :4301, camera side changes
while the clock ran at a month a second:
- Mercury, 12 s from 2026-10-20: 0 (the Sun at most 0.0339 degrees off).
- Venus, 12 s: 0 (2.64). The Moon, 15 s: 0 (1.58).
- Earth, 12 s from 2025: 2, on 2025-03-28 and 2025-09-30, a week after
  each equinox.
- Saturn, 20 s from 2038-06: 1, on 2039-08-03, half a year after its
  equinox (it followed at 2039-01-19 before).
Reviewers had measured 7 or 8 changes in 10 to 12 s on Mercury's page, 3 on
Venus's and 1 or 2 on the Moon's.

Test: 'leaves the camera on its side while the Sun only grazes the equator:
Mercury through two crossings' steps Mercury's page a day at a time from
2026-10-20 over 60 days, through the Sun's crossings about 1 November and
6 December, and expects the camera south throughout. Guarded mutants, each
failing that test alone: the dead band removed (the sign alone chooses),
and the dead band applied to a body just shown too (Mercury then opens with
the camera on the side away from its Sun). The Saturn tests (2032 and past
the 2039 equinox, set to 2045) pass unchanged. Unit suite 867 passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 19:33:04 +02:00
SenrokaiandClaude Opus 5.5 69cada7914 Fail the ETL when re-rating a locked moon moves its pole or W off the kernel's at the present
lockedToOrbit re-rates a locked moon's W and node terms and moves their
constants so that on 2025-01-01, the date the IAU's elements were fitted
near, the pole and W are the kernel's own. Nothing checked it: with the
constants left where they were, the solar ETL passed and the suite passed,
though every re-rated moon moved (Rhea's pole 0.018 degrees, Triton 0.0054,
Miranda 0.0037, Europa 0.0027, Callisto 0.0021, Deimos 0.0016, Ganymede
0.0016, measured on the bodies.json it wrote; before the drawn node rates
were corrected, Mimas's W moved 0.21 and Miranda's pole 0.12).

lockedToOrbit now compares orientationAt at PRESENT_JD before and after,
Iapetus's pole round its orbit included, and throws past 1e-6 degrees.
Measured on the shipped catalogue: at most 4.7e-10 (Deimos's W, some 2.6
million degrees round), the pole exactly.

Guarded mutants, each through the solar ETL on the real catalogue:
- node terms' constants not moved: "Deimos's pole or W on 2460676.5 is
  1.60e-3 degrees from the IAU's".
- W's constant not moved: Deimos, 9.12e-2.
- the check disabled with the first: the ETL passes (control).
The unit suite does not see it: the check runs on the kernel, which only
the ETL reads. The ETL writes the same bodies.json as before. Unit suite
866 passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 19:24:49 +02:00
SenrokaiandClaude Opus 5.5 24c7be1bae Draw four moons' nodes at JPL's current rates, which Horizons and the IAU's poles agree with
The archived satellite table the moons are read from gives older node
periods than JPL's current one for Miranda (17.727 years against URA182's
17.787), Ganymede (132.654 against 137.812), Callisto (338.82 against
577.264) and Titan (704.60 against 687.370), and 74ea1d6 turned the IAU's
poles after those older rates, taking them for the right ones. They were
not: fitted to Horizons' osculating elements on Uranus's equator over
1601-2399, Miranda's node turns 2023.97 degrees a century (rms 0.04),
against the current table's 2023.95, the IAU's U11 2024.22 and the row's
2030.80. That commit moved Miranda's axis from 0.38 to 2.36 degrees of
Horizons' orbit normal. Mimas's node, 0.986 years in both tables, now takes
the IAU's S3, 36505.5 degrees a century, 1.2 from a Horizons fit where
the row's is 4.5.

The ETL spec carries the node period (nodePeriodYears), and the periapsis
keeps the row's longitude rate: the row gives the longitude's rate (Callisto
68.7 degrees a century, the current table 67.2), so the argument takes up
the change. On the row's argument Callisto's periapsis moved 44 degrees by
2100 and it strayed 0.71 degrees from Horizons over 1950-2100.

Worst angle from Horizons' osculating orbit normal (ICRF), HEAD then now:
- Miranda, 1601-2399: drawn orbit 2.11 -> 0.12, axis 2.36 -> 0.34.
- Mimas, 1750-2249: orbit 0.33 -> 0.11, axis 0.65 -> 0.40.
- Ganymede, 1600-2199: orbit 0.17 -> 0.11, axis 0.13 -> 0.01.
- Callisto, 1600-2199: orbit 0.53 -> 0.20, axis 0.02 -> 0.03.
- Titan, 1750-2249: orbit 0.048 -> 0.052.
Against the 1950-2100 Horizons tracks the ETL checks: Callisto 0.19 -> 0.08,
Miranda 1.73 -> 1.62, Ganymede 0.30 -> 0.29, Mimas 7.43 -> 7.42, Titan 0.06.

lockedToOrbit now barely moves the IAU's node terms: Miranda's U11 to
-2023.95, Ganymede's J5 to 261.23 (262.1), Mimas's S3 not at all, and
Callisto's J6, 3.1 per cent from its new node rate, to 62.36 (64.3), which
takes Callisto's axis from 0.56 to 0.22 degrees of its drawn orbit.

With the drawn rates right, leaving every node term at the IAU's rate no
longer failed the ETL (Mimas used to), nor did a 1 per cent tolerance, the
node's angle without its harmonics, or the older node periods. The axis
ceiling is now 0.25 degrees for Europa (0.13 measured), Ganymede (0.16),
Callisto (0.22), Rhea (0.17), Miranda (0.23) and Triton (0.15), and
Callisto's track ceiling 0.15 (0.08). Guarded mutants, each through the
solar ETL on the real catalogue and then the suite on the bodies.json it
wrote:
- node terms left at the IAU's rates: ETL "europa's spin axis leans up to
  0.33"; suite fails only 'turns the poles of Europa, Ganymede, Callisto,
  Rhea, Miranda and Triton round with their drawn nodes'.
- tolerance 1 per cent: ETL, Callisto 0.33; the same test, alone.
- harmonics dropped: ETL, Triton 0.29 (the suite's three dates miss it).
- the archived node periods: ETL, Callisto's track 0.19; the suite fails
  that test and 'draws Miranda's orbit, and turns its axis, where Horizons
  has its orbit in 1601 and 2390' (orbit 2.02, axis 2.36 at 1601).
- the row's argument kept: ETL, Callisto's track 0.71.

The docs that called the IAU's rates the wrong ones are corrected
(lockedToOrbit, the axis ceiling in build.ts, whose Titan node now turns
in 687 years), and the README and BodyRecord doc no longer say every
locked moon's node terms are re-rated: only those within 5 per cent of a
multiple of the node's rate, never the Moon's or Phobos's, and not the
circles Ariel's, Umbriel's, Titania's and Oberon's poles go round on
(0.36 to 0.50 degrees off their orbits). The README also names the five
bodies the IAU's elements do not turn. Unit suite 866 passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 19:22:57 +02:00
SenrokaiandClaude Opus 5.5 ac23496b37 Give the old Earth W's error at AD 1 as the reviewers measured it, not truncated
a15a46c wrote that the IAU's W for Earth, taken at UT + 69.184 s as it was, left Earth's lit face
4.5 degrees off Horizons at AD 1 (4.2 at UT itself), from measurements of 4.56 and 4.28 on JD
1721600 (0001-06-26), where the same sentence rounded AD 1000's 2.29 and 2.00 up. On 0001-01-28
the same two readings are 4.49 and 4.21, so across AD 1 they run 4.5 to 4.6 and 4.2 to 4.3.

body-orientation.ts, which names no date, now says 4.5 to 4.6 over AD 1 (4.2 to 4.3 at UT); the
spec comment, which names JD 1721600, says 4.6 (4.3). Comments only; the assertion (the ERA, 0.051
degrees off there) is unchanged.

Unit suite 864/864, tsc -p tsconfig.app.json and etl:typecheck clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 17:43:53 +02:00
SenrokaiandClaude Opus 5.5 67a21b4a10 Keep the date on screen on a phone after Go, and hand focus back to the tab that folded
09eaf95 folded the Display sheet away on a narrow viewport once a date was set, and said the
strip then read the date. It did only in its text: the dock's tab list could not shrink, the
system view's five tabs take 397 px, and at 360 by 640 and 390 by 844 they pushed the date strip
to x 406-511, past the right edge of a page that does not scroll. With the sheet folded, the set
date was nowhere on screen; before the fold, the open field had at least shown it. And the fold
removed the form that held focus, so focus fell to the page and the next Tab started again at
Search, with nothing announcing the date.

The tab list now gives way (min-w-0) and scrolls (overflow-x-auto), and the date and range strips
keep their width (shrink-0). After a date is set on a phone, focus goes to the tab whose panel
folded, so Enter opens it again.

Live on :4301 in the Sun's system, Display, 2020-12-21T18:00, Enter: at 360x640 the panel folds,
the strip "Date 2020-12-21" sits at x 82-230 and the tabs scroll in 73 px (397 of content); at
390x844 the strip is at 112-260 and the tabs scroll in 103; page scrollWidth equals the viewport
at both, and the tab row stays 36 px high. Focus is on #dock-tab-display at both, and Enter
reopens the panel. At 1400x900 nothing changes: the panel stays open, focus stays in the field,
the tabs take their 437 px. Screenshots dock-go-360x640.png and dock-go-390x844.png, in the
review's scratchpad (wave1/solar/fixr2).

Tests: the tab list carries min-w-0 and overflow-x-auto and the date strip shrink-0 (jsdom lays
nothing out, so the classes are what can be checked there; the positions above are the app's),
and after Go on a phone the focused element is #dock-tab-display. Guarded mutants (full suite),
each failing only its named test: min-w-0 and overflow-x-auto removed, shrink-0 removed from the
date strip ('keeps the date strip on screen on a phone'), and the focus call removed ('hands
focus to the tab that folded').

Unit suite 864/864, tsc -p tsconfig.app.json and etl:typecheck clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 17:42:29 +02:00
SenrokaiandClaude Opus 5.5 7080a5a817 Turn Eris, Haumea, Makemake and Nereid on their pages at their measured days on the map's clock
The body page's comment said the body is drawn at the clock's date and turns at its rate, but a
body with no IAU model turned 0.08 radians a second of wall time whatever the clock said: on
Nereid's, Eris's, Haumea's and Makemake's pages the Clock tab's rates and Backwards changed
nothing (Nereid 4.58 degrees a second at every rate), while the system view turns them at their
measured days on the clock. Hyperion spun the same way on its page and is still in the system
view, where it tumbles and has no day.

A body whose day is measured but whose pole is not now turns pole up at that day, counted from its
orbit's epoch as spinFor counts it; Hyperion is left still; only an exoplanet, whose day no one has
measured, still turns for show. The template comment now says so, and the tick doc names all
five bodies. Live on :4301, degrees a wall second at clock rates 1, 3600 and -3600: Nereid 0.009,
31.05, 31.08 (360/11.594 = 31.05); Eris 0, 0.951, 0.955 (360/378.504 = 0.951); Hyperion 0, 0, 0;
Mars, by its IAU elements, 0.004, 14.67, 14.56.

That makes the sphere reset when a body is shown matter for Hyperion and an exoplanet. The test the
earlier fix added for that reset checked only the light, and deleting the reset passed the suite;
a test now opens Hyperion after Earth and expects the sphere at rest (Earth left it 0.89 radians
turned). Eris's page test checks it stands while the clock stands and turns a sixth of a turn in
a sixth of its 378.504-hour day, pole up.

Guarded mutants (full suite), each failing only its named test: the day ignored and Eris turned
for show ('turns a body whose day is measured'); Hyperion turned for show, and the reset deleted
(both 'puts the sphere back at rest ... Hyperion after Earth').

Unit suite 862/862, tsc -p tsconfig.app.json and etl:typecheck clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 17:33:01 +02:00
SenrokaiandClaude Opus 5.5 a9e910c2ea Keep a body page's camera on its Sun's side when the clock takes the Sun across the equator, aimed at the body in that frame
The page put its camera on the side of the equator its Sun lights once, when a body was shown.
The Clock tab the same page gained in 74535ac changes the date without showing the body again, so
on Saturn's page, opened today with the Sun and the camera south of the rings, Go to 2045 moved
the Sun 25.7 degrees north (sunLight y +3.063) and left the camera south (-0.6): the unlit face
of the rings, the view the camera side was there to avoid. A clock left running through the May
2025 equinox did the same.

tick now remembers which side the Sun stood on at the last frame, and moves the camera across
only when that side changes: when a body is shown (the side is reset to 0 then) and when the Sun
crosses the equator, as the clock runs or is set. Between crossings the camera is the reader's to
orbit, below the rings if they like. The move came after the controls had aimed the camera for
the frame, which was drawn straight after with the body 22.6 degrees (2 atan(0.6/3)) off the
middle of the view; the camera now looks at the controls' target again before that frame.

Live on :4301 (this worktree's ng serve), Saturn's page opened today: camera -0.6, Sun -0.935.
Clock tab, 2045-06-01T00:00, Go: camera +0.6, Sun +3.063, the rings' lit face; 265 frames drawn
across the move, every one aimed within 0.00 degrees of Saturn. The camera then put south by
hand stayed there (-0.6 a second later). On Earth's page the Sun crosses twice a year, so at a
month a second the camera changes side about every six seconds.

Tests: the page followed into 2045 with the camera north and aimed at Saturn in that frame; and a
camera the reader takes over the rings while the Sun stays south is left there, which the old
single-flip rule only held through the flag it cleared and no test covered. Guarded mutants (full
suite), each failing only its named test: the side chosen only when a body is shown ('follows the
Sun across Saturn's equator'); the side never remembered, so the camera is mirrored back every
frame ('leaves the camera where the reader orbits it'); no lookAt after the move ('follows the
Sun across', on the aim).

Unit suite 860/860, tsc -p tsconfig.app.json and etl:typecheck clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 17:27:27 +02:00
SenrokaiandClaude Opus 5.5 2f49002027 Check that a redrawn orbit line reaches the GPU, and hold Saturn to the chords' own sag
The redraw test read the reshaped line's points on the CPU, which reshapeOrbitLine rewrites
whether or not it then sets needsUpdate. three uploads a buffer again only when its version
rises, which only that setter does, so a renderer that dropped the line left J2000's ellipse on
screen and passed the whole suite (857/857). The test now takes Saturn's position version after
drawing J2000 and expects it higher once the clock is at AD 1.

Its Saturn bounds, 0.002 AU at AD 1 and 0.0015 at J2000, sat inside the 128 chords' own sag, which
reaches 0.0032 AU near aphelion, where points spaced evenly in true anomaly lie furthest apart:
the test passed only because Saturn falls near a vertex on those two dates, and a line correct by
construction failed it (Saturn without its a, e and i rates: 0.00229 AU). Both are now 0.0035,
which still catches the line left unreshaped (0.054 AU at AD 1). The renderer's comment put the
sag at 0.003 for Saturn and 0.0005 for Mars; it now says 0.0032 and 0.00055, and where.

Guarded mutants (full suite):
- position.needsUpdate dropped: only the redraw test fails ("expected 0 to be greater than 0");
- the reshapeOrbitLine call dropped: only the redraw test fails;
- Saturn's a, e and i rates removed from bodies.json, line and marker on one ellipse: the
  distance bound now passes (0.00229 < 0.0035); the redraw test still fails, on the version, as
  it should with nothing to redraw, and so does Saturn's AD 3000 Horizons test (0.41 degrees).

Unit suite 858/858, tsc -p tsconfig.app.json and etl:typecheck clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 17:22:28 +02:00
SenrokaiandClaude Opus 5.5 74ea1d655e Turn a locked moon's pole round with its drawn node, so its axis stays on its orbit at every date the clock reaches
The IAU carries a locked moon's pole round its orbit normal on a term of the node's angle, as a
moon in a Cassini state keeps it, but at the node rate the IAU's source had: Miranda's U11 at
-2024.22 degrees a century where the JPL table its orbit is drawn from has -2030.80, Mimas's S3 at
-36505.5 against -36511.16. Over AD 1-3000 that parted Miranda's drawn axis from its drawn orbit
normal by up to 7.89 degrees (AD 9), so Uranus swung 7.6 degrees north and south on its sky every
1.41 days, and Mimas's by 2.63. Only Iapetus's pole had been put on its orbit.

lockedToOrbit now sets every periodic term whose angle turns within 5 per cent of k times the
drawn node rate (k from 1 to 9, the most the report takes, Triton's) to exactly that multiple, and
moves its constant so the angle, and the pole and W with it, are unchanged on 2025-01-01 (the
2025 pole and W of every changed moon are identical to the 1e-14 degree). Measured: the largest
offset taken is Ganymede's J5, 3.4e-2, then Rhea's 1.2e-2; the nearest term that is not a node is
6.0e-2 out (a W-only term of Miranda's), and Umbriel's W has one 1.0e-2 from ten times its node,
which the k limit leaves. Ten moons change: Deimos, Io, Europa, Ganymede, Mimas, Tethys, Rhea,
Miranda, Triton and Proteus. The Moon and Phobos, whose W carries a quadratic, are left as before,
and so is Callisto's J6, 40 per cent from its node rate.

Worst angle between the spin axis and the drawn orbit normal over AD 1-3000, every 135 days,
before and after: Miranda 7.89 -> 0.42, Mimas 2.63 -> 0.47, Rhea 0.77 -> 0.17, Triton 0.51 ->
0.15, Europa 0.33 -> 0.13, Ganymede 0.50 -> 0.16. Faces: Miranda 2.75 -> 2.39, Mimas 8.94 ->
8.89, Deimos 2.14 -> 2.08, Triton 2.88 -> 2.81; none got worse.

build.ts now checks that angle for every locked moon over the same 8114 dates as the face check,
at most 1 degree (Tethys 0.97, whose IAU pole sits 0.69 from its orbit today; Titan 0.94, whose
IAU pole is still while its node turns in 705 years), with four named ceilings: the Moon 7.1 (its
real 6.7-degree tilt, 6.98 at worst), Phobos 2 and Deimos 2 (1.81 and 1.74) and Proteus 1.2
(1.09), whose IAU poles nod with Mars's and Neptune's precessing poles while the Laplace poles
their orbits are drawn round are fixed. The obliquity check at Horizons' epoch shares the new
axisFromOrbitDeg with it. Nothing checked the axis against the orbit before: an Iapetus pole left
on its Laplace pole, 8.30 degrees off at every date, passed the ETL and the suite.

Guarded mutants, each through the solar ETL and then the suite on the data it wrote:
- node terms left at the IAU rates: the ETL fails with "Moon mimas's spin axis leans up to 2.63
  degrees ... (at most 1 expected)", and the suite with only the new test failing;
- Iapetus's pole put on its Laplace pole, W re-phased so its face today is unchanged (worst face
  16.41, under its 16.5 ceiling): the ETL fails with "Moon iapetus's spin axis leans up to 8.30
  degrees", and the suite with only the new test failing.

Also: lockedToOrbit called the Iapetus normal's circle "8.3 degrees across"; 8.3 is its radius (the
row's i = 8.298 to the Laplace plane) and it is 16.6 across. The README credited every rotation to
pck00011 without saying that a locked moon's W and node terms are re-rated to its JPL mean elements
and Iapetus's pole carried round its orbit normal; both of its lines now say so, as the body
model's doc does.

Unit suite 858/858, etl:typecheck and tsc -p tsconfig.app.json clean, solar ETL passes on the
real data.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 17:17:54 +02:00
SenrokaiandClaude Opus 5.5 a15a46c617 Correct what the ETL, the code and the textures README said of their own sources and figures
- The IAU day check said it compared W with the period Horizons states. That holds for the eight
  planets and Phoebe only. Pluto's and Ceres's periods are the IAU's own rate restated (8.5e-12 and
  3.3e-10: Horizons' Pluto period is 360 over its W, and the SBDB notes it derived Ceres's from the
  report's 952.1532 degrees a day), and the 22 locked moons' is their orbit's, from JPL's table, not
  from their Horizons pages ("Synchronous" on eighteen, nothing on Titan's or Proteus's), and now
  their W's own rate. The comment, the log line and the error message say which is which; the log
  shows 0.0e+0 for the twenty locked moons turned at their orbit's rate, 1.1e-8 and 3.1e-7 for the
  Moon and Phobos. BodyRecord.rotationPeriodHours says that where a source states no period, or
  one a later measurement overturns, it is the one the ETL spec carries (Nereid's, Eris's), where
  the previous commit had Eris among the bodies whose source states none.
- Phoebe's spec justified its period by a note in the satellite table, which is about another
  source (Jacobson 2000, Jupiter's outer moons) and says the table carries corrected values. The
  row's n is right as the table defines it, the rate of the mean longitude: n less twice the node's
  rate is 0.6541855 degrees a day, against 360 / 550.30391 = 0.6541840. What made Phoebe drift is
  that the propagator reads a retrograde moon's n as its sidereal rate, as Triton's row gives it.
  The comment now says so; the period, 550.30391 days, is kept.
- body-orientation.ts and its spec said the IAU's W for Earth, "taken at UT", left its face 2.3 and
  4.5 degrees off Horizons at AD 1000 and AD 1. The old code took it at UT + 69.184 s; those are
  that figure's, and at UT itself they are 2.0 and 4.2, as three reviewers measured through the
  code (4.25, 4.25 and 4.28 at AD 1).
- The renderer said the 28 maps entering the Sun's system are about 20 megapixels of JPEG. Read
  from their frame headers: 37.75, nine at 2048 by 1024 with the Sun's, Jupiter's at 3840 by 1920
  and eighteen smaller.
- The textures README gave Titan 0.02 per cent unmapped, counting only the source's zeros. Its
  largest gap is a flat grey of the source's own (147 and 148, its two commonest values), which
  NASA's caption for PIA19658 names as the gap in coverage: measured on titan.jpg, one region of
  1.09 per cent of the pixels, 0.87 of the sphere, at 48-68 N and 37 W to 25 E. The row says 1.1 per
  cent and where, and step 1 says how it is counted; commit 302fa96's "the rest under 0.2%" is
  therefore not true of Titan.

No behaviour changes but the ETL's log and error wording; the solar ETL passes with the new text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 16:16:53 +02:00
SenrokaiandClaude Opus 5.5 dadae7b8ca Give Haumea's card its three semi-axes beside its mean radius
Haumea is triaxial, 1161 x 852 x 513 km (Ortiz et al. 2017, Nature 550, 219), and is drawn as the
sphere of its volume, 797.6 km. Its card listed "Radius 798 km" under Measured and said nothing of
its shape, which only a code comment and a commit message gave: its long semi-axis is 1.46 times
that radius and its short one 0.64.

BodyRecord takes semiAxesKm, set by the ETL from Haumea's spec, and the card then reads "Mean
radius 798 km" and "Semi-axes 1,161 x 852 x 513 km". Every other body keeps its one Radius row. The
ETL checks that a body's radius is the mean of its semi-axes, the radius of the sphere of the same
volume, to 0.1 per cent (797.6 against 797.62).

Measured on :4301, Haumea's page: "Mean radius 798 km, Semi-axes 1,161 x 852 x 513 km". Test: the
card of Haumea as shipped in bodies.json. Guarded mutants: the semi-axes dropped from the spec (run
through the solar ETL), the row, or the view model, or the label left as Radius, each fail it and
only it; a radius that is not their mean fails the ETL ("Haumea's radius, 1161 km, is not the mean
of its semi-axes").

Horizons gives triaxial radii for Phobos, Deimos, Miranda and Ariel too, which the page parser
already reads into their mean; they are not carried here.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 16:12:30 +02:00
SenrokaiandClaude Opus 5.5 fc7677e715 Turn Nereid in the 11.594 hours Kepler measured, where it was drawn still
Nereid's Horizons page states no spin, and the ETL, finding none, left it still; the validator's
comment read that as "Nereid has no spin". Its rotation is measured: Kepler's K2 light curve gives
11.594 +/- 0.017 hours, confirming earlier ground-based periods (Kiss et al. 2016, MNRAS 457, 2908;
arXiv:1601.02395). Its spec now carries that day, as Eris's carries Bernstein et al.'s, and with no
known pole it turns about its orbit normal, as Eris, Haumea and Makemake do. The free-spinner check
accepts it (11.594 hours against a 360-day orbit).

A new validator: a moon without a lock must have a day unless it tumbles, and only Hyperion
("Rotational period = Chaotic") does. Nereid, left without one, fails it: "Moon nereid is drawn not
turning, and is not known to tumble". The renderer spec's example of a body left still was Titan,
said to have no period on Horizons, though it carries its orbit's; it is Hyperion now, and
BodyRecord.rotationPeriodHours says where each kind of period comes from.

Measured: the solar ETL passes; in bodies.json only Hyperion has no rotationPeriodHours; live on
:4301 Nereid's marker turns 60.000 degrees in a sixth of its day. Test: Nereid, as shipped, turns 60
degrees in 1.93 hours. Guarded mutant, the day removed from its spec and run through the solar
ETL: the validator fails, and the suite on the data it wrote fails that test and only it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 16:02:30 +02:00
SenrokaiandClaude Opus 5.5 e8e857ec25 Hold Io and Europa to their own track ceilings, and Hyperion's one-date ceiling to just above its offset
Io's and Europa's periapses turn backwards, held by the Laplace resonance (apsidesRegress), which
brings them to 0.07 and 0.23 degrees of Horizons from 1950 to 2100. Nothing guarded the flag: with it
dropped the ETL still passed, Io at 0.96 and Europa at 2.24 under the general 3-degree track
ceiling, their cards quietly rewriting themselves to "within 1.0" and "within 2.3". They now have
ceilings of 0.2 and 0.5, as Tethys has for the term it takes from its W. The comment on the
one-date check, which said it catches the periapsis run the wrong way, now says it does not
(0.904 against 2.5) and which check does.

Hyperion's one-date ceiling was 21 degrees, described as just above its offset on that date, where
it is 9.413: the 21 was its worst over twelve dates in an earlier check. It is now 10.

Measured on the real catalogue with the solar ETL: Io 0.07, Europa 0.23 at worst, Hyperion 9.413,
all passing. Guarded mutants, run through the solar ETL: apsidesRegress removed from both specs
fails with "Io's mean elements put it up to 0.96 degrees ... (at most 0.2 expected)"; Hyperion's row
misread by 6 degrees of node fails the one-date check at 15.41, which 21 let through.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 15:59:09 +02:00
SenrokaiandClaude Opus 5.5 3b4fd1af6c Turn each locked moon at its orbit's rate and Iapetus's pole round its orbit, so they face their planets at every date the clock reaches
The IAU gives a locked moon's W the mean motion of whichever orbit its authors had, and JPL's table
has another. Near the present the difference is nothing; over the clock's AD 1 to 3000 it turned
Proteus's far side to Neptune at AD 1 (146 degrees), Mimas 52 degrees from Saturn and Miranda 23.
Iapetus was worse for another reason: its IAU pole is a straight line, 3.9 degrees a century in
right ascension, through its orbit normal's 3 439-year circle round the Laplace pole, which by AD 1
has run past the celestial pole (Dec 97.9), 11 degrees off the orbit, with the face 87 degrees
from Saturn. Mimas and Iapetus carry mission maps, so a wrong hemisphere was drawn facing Saturn.
The ETL's lock check sampled only 1950-2100, so none of it failed.

tools/etl/lib/locked-spin.ts, lockedToOrbit, called for every locked moon:
- W's rate becomes the orbit's own mean motion, its constant moved so W is unchanged on
  2025-01-01; the pole and every periodic term stay the IAU's. A W with a quadratic is left
  (Phobos's orbit already takes it; the Moon's is its tidal slowing, 0.75 degrees at AD 1). The
  kernel's rate must be within 1e-5 of the orbit's first (at most 3.4e-6, Iapetus).
- Iapetus (poleFollowsOrbit): the pole follows its orbit normal, as a moon in a Cassini state does,
  in the IAU's own form: sines of the node's angle and four harmonics on right ascension, cosines
  on declination, fitted to the normal's circle and pinned to the IAU pole at the present; W takes
  sines of the same angles, fitted to hold the face where it is today.

build.ts samples the lock over AD 1 to 3000 (8 114 dates, every 135 days) instead of 1950-2100, and
subPlanetLongitudeDeg moved to the lib, shared by both. Measured on the real catalogue: at most
5.36 degrees (Titan) but the Moon 7.62 (its eccentricity, and W's quadratic at AD 1: a new named
ceiling of 8), Mimas 8.94 (ceiling 11 -> 9.5) and Iapetus 15.95 (19 -> 16.5, 9.4 of it its row's
lag); Proteus's own ceiling of 9 is gone, at 2.66. Iapetus's axis stays within 0.74 degrees of its
orbit normal (11.06 before) and its pole is the IAU's at the present to 1e-4 degrees.

Live on :4301, the longitude facing the planet at AD 1 / 1000 / 2025 / 2999: Proteus 2.6 / 2.6 /
2.6 / 2.6 (was -146.5 / -72.9 / 2.6 / 74.5), Iapetus -15.9 / -15.4 / -15.3 / -9.3 (-87.0 / -50.9 /
-15.3 / 22.8), Mimas 4.1 / 6.8 / 5.7 / 8.4 (48.6 / 29.3 / 5.7 / -13.0), Miranda -0.1 / 2.2 / 0.0 /
1.4 (-23.4 / -9.6 / 0.0 / 12.6); the present is unchanged.

The renderer spec now takes the rotational elements from bodies.json too, so no hand copy is left,
and a new test turns Proteus, Miranda, Mimas and Iapetus to their planets at AD 1 and AD 3000.
Guarded mutants, each run through the solar ETL and then the full suite on what it wrote:
lockedToOrbit bypassed (validator: Mimas 52.30, ceiling 9.5; the new test fails), Iapetus on the
IAU's straight pole (98.48), and its pole round the orbit without W's terms (73.13); each fails the
new test and only it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 15:58:04 +02:00
SenrokaiandClaude Opus 5.5 09eaf9532f Keep the Display panel shorter on a phone, and fold it away once a date is set
The date form made the Display panel 400 px tall at 360x640 (from 257) and 363 at 390x844 (from
220), and the Sun's system sat behind it: every orbit at 360x640, 81 per cent of their points at
390x844. A reader who set a date could not see what it did without closing the panel.

The window's description is one line, "AD 1 to AD 3000, where the planets' elements hold." (the
sentence on how far each moon's orbit strays is on each card and in the system note), and below
sm the field shrinks so Go stays on its line. Measured on :4301 with the panel open: 318 px at
360x640, Go at y 501 beside the field at 500; 318 px at 390x844, where 9 per cent of the orbits'
points are behind it and 37 of the 39 lines show.

At 360x640 the system is framed behind even the old 257 px sheet, so a date submitted with Go on
a narrow viewport now folds the sheet, as choosing a search result already does. Measured: after
Go at both sizes the panel is gone and the strip reads "Date 2020-12-21". Wide screens keep it
open.

Tests: the sheet folds after Go on a narrow viewport, and stays open on a wide one; jsdom has no
matchMedia, so the spec gives the dock one it can turn narrow. Guarded mutants: no fold fails the
first, a fold on every screen the second (and "jumps the clock to the date submitted", which then
cannot find Back to now).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 15:44:02 +02:00
SenrokaiandClaude Opus 5.5 74535accc5 Show the date and the clock on a body's page, and open it on the side of the equator its Sun lights
Since the page turns a body as it stands at the map's date, it has been drawn for a date it never
showed, at a rate it gave no way to change: set to 2032 at a day a second in the system view,
Saturn's page ran on at that rate with only Search and Bookmarks on its dock, and at a month a
second Earth turned about 183 degrees a frame at 60 Hz. The dock now takes `clock`, which offers
the clock without the layers, as a tab named Clock, and the page binds it with the date strip the
system view already has. Measured on :4301 at 2032-06-01: the strip reads "Date 2032-06-01", the
tabs are Search, Bookmarks and Clock, and the Clock panel has the four rates and the date field;
Back to now clears the strip.

The camera opened 11.3 degrees north of the equator whatever the season, which since 2025 put
Saturn's page on the unlit face of its rings, and will until 2039. When a body is shown, the next
frame puts the camera on the side of the equator the Sun is on: measured on Saturn's page, the
Sun at -26.71 degrees on 2032-06-01 and the camera at -11.31; Earth's in June stays north.

Tests, each proved by a guarded mutant that fails it:
- the page follows the clock after its first frame (frozen at the first frame: 90 degrees out);
- a body with no IAU model shown after Earth gets the page's own light back (left at Earth's Sun);
- the page's dock shows the date and a Clock tab, and no date at the present (date never set, or
  the dock bound without the clock);
- the dock offers the clock alone as a Clock tab (no tab, or a tab still named Display);
- Saturn's page in 2032 opens with the camera and the Sun both south (camera held north).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 15:42:58 +02:00
SenrokaiandClaude Opus 5.5 28fa79e1e0 Check the Horizons vectors against the bodies.json the app ships, and every rate Standish's rows give
The frozen Horizons tests ran on a hand copy of seventeen records, so an ETL that lost Standish's
a, e and i rates, or Io's and Europa's backward periapses, wrote a bodies.json that passed both
its own validators and the whole unit suite: the data-refresh job's "Unit tests against the new
data" read none of it. record() now takes kind, orbit, rates, laplacePole, parentBodyId and
massRatio from src/assets/data/bodies.json, read with node:fs as texture-catalog.spec.ts reads its
JPEG, and the copy is gone. Today's data passes as the copy did (all seventeen were identical).

The parser test checked only the mean motion and the periapsis rate on Earth's row. It now checks
the node, a, e and i rates too, against Standish's Table 2a (-0.24123856, -0.00000003, -0.00003661,
-0.01337178 a century). Dropped, those rates move Saturn 0.66 degrees at AD 1 (node) and 0.36 at
AD 3000 (a, e, i), where no date from 1950 to 2100 shows more than 0.036.

Guarded mutants, each run on the full suite:
- bodies.json without the a, e and i rates, as that ETL writes it: 'puts saturn within 0.1 degrees
  of Horizons on JD 2816787.5' and Earth's fail (and the new orbit-line test, on the same data).
- bodies.json with Io's and Europa's periapsis rates turned positive: Io's and Europa's 1950
  tests fail.
- the parser without its a, e and i rates, and with a node rate of 0: 'gives the rates per day'
  fails, and only it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 15:25:41 +02:00
SenrokaiandClaude Opus 5.5 3e1bd33b6c Draw every body in a system on one shared sphere, so returning to the Sun's system is no long task
Each marker built its own 64 by 32 SphereGeometry, and the Sun's system now has 38 of them: the
renderer's constructor took 15 ms, 12 of them building spheres, and with their first upload a
return to the system made a long task of 52 to 70 ms that the base's 18 bodies never did.

Every marker is now the one unit sphere, scaled to its radius, which it keeps in
userData.radiusAu. The shared sphere is never disposed; Saturn's ring is built in the sphere's
own units, since it is the marker's child; keepMarkersLegible reads the stored radius and scales
against the sphere's.

Measured on :4301, eight returns to the Sun's system each (select null, then 0, at 1600x1000):
before, a long task on 3 of 8 (52-57 ms), swapToSystemSpace 13-17 ms and the first render 30-40;
after, no long task on 8 of 8, the swap 2.7-4.4 ms and the first render 20-38. Earth is drawn at
the same 0.656 AU at arrival, and every member shares one geometry.

Tests: one sphere for every marker, each at bodyMarkerRadiusAu of its radius, and not disposed
with its system; Earth held to its 3-pixel floor at the arrival framing, which no test covered.
Guarded mutants, each failing only its named test: a sphere per marker, the shared sphere
disposed, the ring built in AU inside the scaled marker ('picks Saturn through its rings'), and
the legibility scale divided by the body's radius.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 15:14:02 +02:00
SenrokaiandClaude Opus 5.5 c2683da37b Redraw a planet's orbit line as its axis and eccentricity drift, so Saturn stays on it at AD 1
The orbit lines kept the shape of the J2000 elements and only turned with the node, while the
markers moved on Standish's drifting a and e. Saturn's eccentricity falls 0.00032 a century, so
at AD 1 its line passed 0.056 AU (8.4 million km) from Saturn, Jupiter's 0.016 AU from Jupiter,
and Pluto's 0.021 AU from Pluto at AD 3000. The comment that said no drawn line shows the drift
weighed one century of Pluto's axis, not twenty of Saturn's eccentricity.

update() now writes the line's 129 points again once |da| + a |de| since they were drawn passes
1e-4 AU, well under the 128 chords' own sag. Measured in the app on :4301, marker to its own
polyline: Saturn 0.0016 AU at AD 1 and 0.0028 at AD 2999, Jupiter 0.0014 at AD 1, Pluto 0.0078
at AD 2999 and 0.0082 today, all the chord sag.

The existing Mars test checked only that Mars stays in its line's plane. A new test measures the
distance to the drawn chords: Saturn 0.0017 AU and Mars 0.0005 at AD 1 (0.054 and 0.0022 without
the redraw). Guarded mutants: the call removed, and the threshold raised to 1 AU, each fail it
and only it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 15:01:08 +02:00
SenrokaiandClaude Opus 5.5 d449214309 Stop calling WASP-108 b imaged, and stop saying no map exists of an imaged exoplanet
48fd6fd took the archive's ima_flag as it stood, so WASP-108 b's card said it had been imaged as a
point of light beside its star. It is a transiting hot Jupiter (period 2.676 d, 0.04 AU out, 0.15 mas
at 258.8 pc). Its flag comes from Bohn et al. 2020 (A&A 635, A73), a VLT/SPHERE survey of transiting
planets' host stars, which imaged a 0.35 solar-mass companion 0.124" from WASP-108, not the planet.
The imaged query now also asks for tran_flag=0. The archive flags exactly one transiting planet as
imaged (queried today: WASP-108 b); with it left out the list has 101 names, the old 102 less that
one, and still above the validator's floor of 95. The new URL is cached under its own name, and
exoplanets.json, regenerated by the full ETL, changes by that one record's field and nothing else
(checked record by record against HEAD).

build.ts now fails if WASP-108 b is marked imaged. The count cannot see a false positive, and its
record carries neither a period nor an axis, so no separation check could. Control: the full ETL on
the old query (its cache still present) fails with "WASP-108 b is marked as imaged; it transits, and
only a companion star beside it was imaged (Bohn et al. 2020)."

The imaged sentence ended "and no map of it exists". Luhman 16 b, one of the 101 and a brown dwarf,
was mapped by Doppler imaging (Crossfield et al. 2014, Nature 505, 654). It now reads "and no map of
it is used here", which holds for all of them; the doc comment names Luhman 16 b. Control: the old
sentence fails "says a directly imaged exoplanet was seen as a point of light" only (1 failed, 843
passed of 844). Live on :4301: WASP-108 b and Kepler-22 b read "no image of this world exists";
Luhman 16 b and HR 8799 b read the new sentence.

48fd6fd missed a third copy of the claim it corrected: the body page's comment still said every
exoplanet gets a derived surface "since none has ever been imaged". It now says none has had its
surface imaged, as planet-appearance.ts and the README do.

A correction to 48fd6fd's message: HR 8799 b, c and d are Marois et al. 2008 (Science 322, 1348),
and e is Marois et al. 2010 (Nature 468, 1080), not "b to e (Marois et al. 2008)".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 23:23:11 +02:00
SenrokaiandClaude Opus 5.5 936d1c01f8 Leave a system outwards from wherever the camera stands, and test that the scene frames the furthest it draws
Leaving a system flew the camera to a fixed 400 AU. Since c45d916 frames the Sun's system on Eris's
97.7 AU aphelion, a phone held upright arrives 508 AU out, so leaving drew the system 21 per cent
nearer during the 0.9 s exit (review, 390x844: 507.9 -> 400.0 AU). The exit now flies to 400 AU or
half as far again as the camera already stands, whichever is further. Measured live on :4301, the
distance from the Sun through the exit, first and last frame:

  390x844    507.9 -> 761.8 AU   (was 507.9 -> 400.0)
  360x780    508.5 -> 762.7
  768x1024   312.9 -> 469.3      (was 312.9 -> 400)
  1600x1000  234.7 -> 400.0      (unchanged)

and never nearer in between. The swap puts the camera at GALAXY_APPROACH_DISTANCE_PC whatever the
exit distance, so only the animation changes.

No test checked that the scene hands outermostRadiusAu to the framing: framing on the 67.9 AU
semi-major axis instead, which on a square window puts Eris off screen on arrival, passed all 841
tests. The scene spec's fixture held Earth alone. A describe now adds Eris (a = 67.934, e = 0.4382):
- "frames the furthest the system draws" requires the settled camera to stand at
  systemFramingDistanceAu(outermostRadiusAu) from its target, 234.7 AU at aspect 1.
- "leaves the system outwards even from a phone's framing" enters at aspect 390/844, then samples the
  camera each frame of the exit until the swap: never nearer, and further at the end.

Controls, each run on the full suite:
- outermostRadiusAu -> maxTopLevelSemiMajorAxisAu in the scene fails both (2 failed, 842 passed);
  the second fails too because the arrival is then under 500 AU.
- Math.max -> Math.min in the exit (back to 400 AU) fails the exit test only (1 failed, 843 passed).

The framing spec's fit() said it was "what the scene actually composes"; it frames the ring only, so
its comment now says it is the grid's half and where the aphelion is tested.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 23:17:00 +02:00
SenrokaiandClaude Opus 5.5 f6bb2b9584 Pin the 1972 hand-over to the leap seconds from the later side too
The test named for the hand-over caught the switch moved earlier and passed with it moved up to six
months later: both samples round midnight then fall on the polynomial (a step of about 0), the last
day of 1971 still reads 42.2485 s, and the 1972-06-30 = 42.184 check only sees a move past June.
Such a switch leaves TT - UT up to 0.59 s high through the first half of 1972. The test now also
requires 1 January 1972 itself to read the table's 42.184 s, the 10 s TAI - UTC began with plus
32.184.

Control: the switch moved to JD_1972 + 120 now fails "hands over from the polynomial to the leap
seconds at the start of 1972" only (1 failed, 843 passed of 844); before, the suite passed 841 of 841.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 23:16:48 +02:00
SenrokaiandClaude Opus 5.5 83dc46416b Test an exoplanet's aphelion in the framing radius and a derived surface's white, and give Earth's old TDB error as 8.6 degrees
Two lines the review found unguarded, each now held by a test that fails without it:

- outermostRadiusAu takes an exoplanet's eccentricity as well as a solar-system body's. c45d916 said
  303 of the 1 190 exoplanet systems have an aphelion past their grid ring, but both tests built the
  system from a BodyRecord. The new case is HD 20782 b (a = 1.3649 AU, e = 0.95), the most eccentric
  of them, whose aphelion is 1.66 times its 1.6 AU ring. Control: feeding eccentricity 0 for
  exoplanets fails "reaches an exoplanet's aphelion too" only (1 failed, 843 passed of 844).
- A derived surface resets its marker to white once painted, as a photograph does. Without it every
  exoplanet, the five Uranian moons, Proteus, Nereid, Hyperion, Eris, Haumea and Makemake would show
  their texture multiplied by the kind's flat colour. "paints them after the system is built, one a
  task" now asserts the exoplanet magenta before and 0xffffff after. Control: deleting
  material.color.set(0xffffff) on that path fails that test only (1 failed, 843 passed).

The AD 1000 Earth test's comment said the IAU W taken at TDB left the drawn face 6.6 degrees off.
6.6 is only the turn ΔT adds (1 572 s at 360.9856 deg/day); it adds to the W's own lead, so the
face is 8.57 degrees off. Measured here by putting Earth back on the IAU W at TDB in the renderer:
"expected -8.565166921766826". The 2.3 degrees the comment gives for the old code is the W at
UT + 69.184 s; at UT itself it is 2.0, and the comment now says both.

cbe4908's table also gave the 2025-06-01 12:00 row as "+0.06 before, -0.003 after". Two reviewers
measured the same Horizons point (1.579501 E) at +0.087 before and +0.0034 after: the row should read
+0.09 and +0.003. The message cannot be changed without rewriting history, so it is restated here.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 23:16:40 +02:00
SenrokaiandClaude Opus 5.5 acc4b928f2 Say that Makemake's day is known only to a factor of two, citing both readings
f655a4c kept Makemake's SBDB period, 22.83 hours, saying "nothing later overturns it", a reading
its author had not checked. The SBDB flags that period as Eris's was ("may be wrong by 30 percent
or so"). Hromakina et al. 2019 give 22.8266 h as the double-peaked period a "possible lightcurve
asymmetry suggests", of a light curve that repeats every 11.4 h. Kiss et al. 2024 (arXiv:2410.22544),
with TESS and Gaia, find the 11.401 +/- 0.076 h single peak again, "cannot confirm that the
double-peaked 22.8 h is Makemake's true rotation period", and take 11.4 h as their default.

Nothing is changed in what is drawn: the literature does not settle which it is, and 22.83 stays as
the SBDB's. The Makemake spec now cites both papers and says the drawn day may be twice the real
one, and the renderer's comment no longer calls the three dwarf planets' periods known.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:50:29 +02:00
SenrokaiandClaude Opus 5.5 4cd08ee324 Credit the IAU W on the three moons whose orbits take terms from it, guard Tethys's, and say what Mimas's lock ceiling is
Since f8ee3ac, Mimas, Tethys and Phobos move by terms taken from their IAU W in NAIF's pck00011:
Mimas's -44.85 degree S5 term, Tethys's +2.23 on the same angle, and Phobos's tidal quadratic,
which also re-centred its M0 (91.059 to 94.239) and n. Their cards still credited only "JPL SSD
satellite mean elements". They now read, for Mimas, "JPL SSD satellite mean elements, epoch 2000
Jan 1, with the orbital terms of its IAU W (NAIF pck00011), within 7.5 degrees of Horizons from
1950 to 2100"; Tethys and Phobos likewise (0.3 and 1.3). build.ts requires any moon carrying such
terms to name the kernel. Control: the full ETL with the old card fails with "Phobos's orbit
carries terms taken from its IAU W, and its card ... credits only the table."

Tethys's half of the libration had no guard. Without its term it strays 2.09 degrees from Horizons
over 1950-2100 against 0.28 with it, under the general 3-degree ceiling, and its card rewrote
itself as "within 2.1"; no unit test loads bodies.json. Tethys now has its own track ceiling of
0.5 degrees. Control: the full ETL without Tethys's orbitFromW fails with "Tethys's mean elements
put it up to 2.09 degrees from Horizons ... (at most 0.5 expected)".

The lock ceiling's comment said Mimas's 10.15 degrees was "physical libration, which W carries and
Horizons shows as 5 to 9 degrees". Neither holds: Mimas's measured physical libration is 0.84
degrees (Tajeddine et al. 2014), W carries no such term, and Horizons, on the same W against its
integrated orbit, runs from -2.7 to +12.7 degrees over 1950-2100 (the review's scan every 20
days). The drawn face's 2.5 to 10.15 is the IAU's W and JPL's mean longitude disagreeing by about
6.3 degrees, W turning 6.0e-5 degrees a day faster than the row's n (381.9945550 against
381.9944948, 3.3 degrees over the span), and 2e = 2.3 degrees of eccentricity. The ceiling itself
is unchanged.

The baseline ETL from the cache passes and changes bodies.json on those three cards only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:50:21 +02:00
SenrokaiandClaude Opus 5.5 48fd6fd0e5 Stop telling the reader that the hundred directly imaged exoplanets were never imaged
Every exoplanet card without a map ended "Not an observation — no image of this world exists.",
and 395b613 wrote that rule into a comment ("Only an exoplanet has never been imaged") and a test.
The NASA Exoplanet Archive flags 102 planets as detected by imaging (ima_flag), all 102 of them in
exoplanets.json: HR 8799 b to e (Marois et al. 2008), bet Pic b, 51 Eri b, AF Lep b, and bet Pic c
and eps Ind A b, found by radial velocity and imaged since. The review read the sentence on the
live pages of HR 8799 b, 51 Eri b and bet Pic b.

The ETL now asks the archive for those names in a query of its own, cached apart from the main
table so the other 6 252 planets stay on the snapshot they were built from, and carries
`imaged: true` on the matching records. exoplanets.json changes by that field on 102 records and
nothing else (compared record by record). Their cards now end "Not an observation — it has been
imaged only as a point of light beside its star, and no map of it exists."; the rest keep "no
image of this world exists". The provenance comment and the texture catalogue's comment say which
is which.

Checked live on :4301: HR 8799 b and eps Ind A b carry the new sentence, Kepler-22 b the old one.

Tests: body-view-model.spec 'says a directly imaged exoplanet was seen as a point of light, not
that no image of it exists'; the old test is renamed 'says an exoplanet the archive does not flag
as imaged has no image'. Unit controls, each failing that test only (1 failed, 840 passed): the
provenance ignoring the flag; the view model dropping it. build.ts now requires at least 95
imaged planets (measured 102); control, the full ETL with the join made on the host's name
instead of the planet's, fails with "Only 0 exoplanets are marked as imaged".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:50:04 +02:00
SenrokaiandClaude Opus 5.5 c45d9160e3 Frame the Sun's system on Eris's aphelion, the furthest it draws, not on the grid ring inside it
The scene framed the grid's outer ring, sized from the largest semi-major axis, and two comments
said the ring was "always the wider of the two, by construction". That held until Eris came in:
its a = 67.93 AU gives an 80 AU ring, but with e = 0.438 its orbit reaches 97.7 AU, and Eris is
95.5 AU out now. The 12 per cent margin protected the ring, not Eris. The review measured Eris's
orbit at 0.982 of the half-width on a 390x844 phone (3.5 px from the edge), 0.987 on 1000x1400,
and on a 1000x1000 window Eris's marker at NDC 1.002, off screen on arrival.

SystemOrbitsRenderer.gridOuterRadiusAu becomes outermostRadiusAu: the ring, or the largest
top-level aphelion a(1 + e) where that runs past it. The scene frames that. Some orbit runs past
its ring in 303 of the 1 190 exoplanet systems too (counted on exoplanets.json), and they are
framed the same way. The 500 AU ceiling rises to 600: the aphelion needs 508 AU on a 390x844 phone,
and 600 holds it with its whole margin down to an aspect of 0.39. The comments are corrected.

Measured in the app on :4301 after entering the Sun, Eris's drawn orbit, largest |NDC x| over its
129 vertices (review's figures before):
  390x844    camera 507.9 AU  0.804  (0.982)
  1000x1400  camera 328.5 AU  0.805  (0.987)
  1000x1000  camera 234.7 AU  0.812  (1.004, marker off screen)
  950x1000   camera 247.0 AU  0.810
  1600x1000  camera 234.7 AU  0.508  (0.627)
No orbit vertex of Neptune, Pluto, Eris, Haumea or Makemake is off screen at any of them. The
cost: inner bodies arrive smaller, the landscape camera 235 AU out instead of 192.

Tests: renderer 'reaches as far as an eccentric orbit goes past the grid: Eris's aphelion, 97.7
AU, not the 80 AU ring' (and the ring where every orbit stays inside it), and framing 'leaves
Eris's aphelion its whole margin in every window shape'. Controls, each failing its named test
only (1 failed, 839 passed): framing on the ring alone; the ceiling back at 500 AU.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:24:23 +02:00
SenrokaiandClaude Opus 5.5 3453cd5d9f Say which cards give how far their orbit strays, since Pluto's does not
The date field's description read "Each moon's and dwarf planet's card says how far its orbit
strays from 1950 to 2100." Pluto is a dwarf planet on its card, but it moves on Standish's planet
elements, and the ETL measures only moons and the SBDB bodies against Horizons: its card ends
"Orbit: JPL approximate mean elements (Standish), fit for 3000 BC to AD 3000." and gives no stray
figure. In bodies.json, Ceres (7.2), Eris (0.1), Haumea (0.4), Makemake (0.3) and every moon carry
one; Pluto does not.

The description now reads "AD 1 to AD 3000, where the planets' and Pluto's elements hold. Each
moon's card, and Ceres's, Eris's, Haumea's and Makemake's, says how far its orbit strays from 1950
to 2100." The CLOCK_WINDOW comment and the scene's note comment make the same distinction.

Test: hud-dock 'opens the date field on the clock's date...' asserts the new sentence. Control:
putting the old sentence back fails that test only (1 failed, 836 passed).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:18:13 +02:00
SenrokaiandClaude Opus 5.5 85cb66e63c Test that a photograph shows in its own colours once it reaches its body
Since 2d4b8a9 a photographed marker is built in its kind's flat colour (a planet's 0.55, 0.75, 1.0,
a moon's 0.75 grey, a dwarf planet's 0.8, 0.7, 0.55) and only showNextPhotograph sets it back to
white when the map goes on. The material multiplies its map by its colour, so without that one line
every photograph would be tinted: each planet's turned blue, Mars's red cut by 45 per cent. The
review's mutant that drops it passed all 835 tests; the photographs test only looked at the map.

The test now also reads each material's colour: the kind's colour before the image loads, and
0xffffff for both bodies once their maps are on. Controls, each failing that test only (1 failed,
836 passed): showNextPhotograph without the white reset; and the marker built white from the start.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:16:04 +02:00
SenrokaiandClaude Opus 5.5 20c34f9662 Test the 1972 hand-over to the leap seconds where it happens, and give the ΔT comment its measured joins
The continuity test sampled 1972 at 2451544.5 + (1972 - 2000) x 365.2425 +/- 0.01 d, which is JD
2441317.70 and .72; the switch is at the calendar's 1 January 1972, JD 2441317.5, so both samples
read the leap-second table (42.184 and 42.184) and the join was never compared. Moving the switch
two years early (a 1.99 s step in 1970) passed all 836 tests. The hand-over now has its own test:
the step across midnight must be under 0.1 s (it is 0.067: 42.2514 to 42.184), and the last day of
1971 must still read the polynomial's 42.2485 s. Control: the switch at JD_1972 - 730 fails that
test only (1 failed, 836 passed).

The polynomials' own joins, measured 1e-6 d either side: 0.251 s at 1600, 0.162 at 1700, 0.087 at
500, 0.088 at 1900, and under 0.06 elsewhere. 34ae803 said its pieces join within 0.1 s; they join
within 0.26 s, a step in NASA's published Espenak-Meeus coefficients, which the code copies as
they are. The test's bound is tightened from 1 s to 0.3 s to say so. Control: starting the
1600-1700 piece 0.5 s high (a 0.75 s jump, which the 1 s bound let through) fails it only.

Comments in constants.ts: TT - UTC from 1972 is 32.184 s plus TAI - UTC, the 10 s UTC started from
and the 27 leap seconds since (it read "the 10 to 37 of them"). The Moon's error when ΔT was held at
69 s is 0.21 to 0.26 degrees at AD 1000 and 1.44 to 1.79 at AD 1 depending on where it is on its
eccentric orbit, not a single 0.22 and 1.43.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:13:09 +02:00
SenrokaiandClaude Opus 5.5 cbe4908219 Turn Earth by the Earth Rotation Angle, so its lit face stays Horizons' at AD 1 as it is today
Earth was turned by its IAU W, taken at the clock's UT plus today's 69.184 s. That W is a straight
line fitted to the present: 360.9856235 degrees a day, which, once its pole's -0.641 degrees a
century in right ascension is counted, runs 6.3e-6 degrees a day slow of Earth's real turning. The
followsUt comment said the clock's date "already says how far it has turned"; it did not. Against
Horizons (observer quantity 14 from the Sun, TIME_TYPE=UT, Earth one light-time back) the drawn
sub-solar point was 2.3 degrees off at AD 1000 and 4.5 at AD 1.

Earth is now turned by the IERS Earth Rotation Angle (IERS Conventions 2010, eq. 5.15) at the
clock's date, counted from the node the IAU's W starts at, 90 degrees past the pole's right
ascension. The pole is unchanged. Drawn minus Horizons, in degrees:

  date                       before    after
  2025-06-01 12:00 (unit)     +0.06   -0.003
  AD 1000, JD 2086455 (unit)  -2.3    -0.001
  AD 1, JD 1721600 (unit)     -4.5    +0.051
  live app, :4301, same probe as the review's
    JD 2460900.25             +0.089   +0.005
    JD 2086300.5              -2.281   +0.010
    JD 1800000                -4.049   +0.056
    JD 1721450.75             -4.530   +0.072

At noon UTC on 1 June 2025 the Sun now stands over 0.52 W on the drawn sphere, where the equation
of time puts it at 0.53 W (0.43 W before).

TT_MINUS_UTC_DAYS had no other use and is removed; its comment also counted 37 leap seconds where
UTC has taken 27 on top of the 10 s it started from in 1972.

Tests: body-orientation.spec 'lights Earth's face where Horizons does at the far end of the clock
too: AD 1000 and AD 1' (within 0.15 degrees), and the renderer's AD 1000 Earth test now checks the
drawn face against Horizons instead of against the IAU W the old code used. Control: turning Earth
by its IAU W at UT + 69.184 s again fails both named tests (2 failed, 834 passed). The README says
which model turns Earth.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:09:54 +02:00
SenrokaiandClaude Opus 5.5 dc7277f740 Say in the textures README that the mission mosaics' brightness is not albedo, as Iapetus shows
Iapetus's leading hemisphere has an albedo of 0.03-0.05 and its trailing one 0.5-0.6, about a
tenth. On iapetus.jpg, between 30 S and 30 N, the leading side (30-150 W) averages 83.4 of 255 and
the trailing (30-150 E) 108.2, a ratio of 0.77, measured here again; the USGS source gives the same
(83.3 and 108.1), so it is the mosaics' frame-by-frame contrast stretch, not the processing. The
README's Iapetus row checked only where Cassini Regio lies, and nothing said the brightness is not
albedo; it now does, with those figures. The map is not rescaled: no photometric model was applied
to any body, and one hemisphere's worth of scaling would be invented for this one.

Documentation only; no behaviour changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:58:52 +02:00
SenrokaiandClaude Opus 5.5 d93bb1ee2c Say that Pluto's tilt is checked against its own IAU pole, not against Horizons
The ETL's obliquity check, its failure message and the renderer spec's test name all compared
Pluto's drawn tilt with "the obliquity Horizons gives", as 1c86584 and cdf474b said too. Horizons'
Pluto page states none (grep -i obliq finds only the page's IAU76 frame note); the 119.6 degrees is
hand-entered in fetchSolarSystem.ts from the IAU WGCCRE 2015 pole (RA 132.993, Dec -6.163), which
with Pluto's orbit gives 119.609. So for Pluto the check confirms that the kernel's pole and the
sense of W were read as written, which it would still catch misread, but not the pole against a
second source.

The build.ts comment says so, its message names Pluto's reference as its IAU pole, and the spec's
test name and comment no longer attribute Pluto's tilt to Horizons. Labels only; no behaviour
changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:58:33 +02:00
SenrokaiandClaude Opus 5.5 98c4eb901f Credit the sources the solar system's orbits now come from, in the README and a search comment
Since 48319c3 and 1d42be2 no position comes from Horizons: the planets move on Standish's mean
elements (Table 2a/2b), the moons on JPL SSD's satellite table, Ceres, Eris, Haumea and Makemake
on the Small-Body Database's osculating elements, and every body turns by the IAU's rotational
elements from NAIF's pck00011. bodies.json's orbitSource values say so (9 Standish, 25 satellite
table, 4 SBDB, none Horizons). Horizons gives sizes, spins and the positions the ETL checks
against. The README still credited "Solar-system ephemerides: NASA/JPL Horizons", said the Sun's
bodies came "from JPL Horizons", listed fetchSolarSystem's source as "JPL Horizons / SSD", and had
Horizons reporting every element against the ecliptic, where the moons' are against a Laplace
plane or their planet's equator. The branch had edited the credits paragraph and left that line.
search-ranking.ts called the solar-system bodies "eighteen famous objects"; there are 38.

Documentation only; no behaviour changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:57:50 +02:00
SenrokaiandClaude Opus 5.5 a281f22f34 Measure every moon and dwarf planet against Horizons from 1950 to 2100, and say on its card how far it strays
The clock reaches AD 1 to AD 3000, but only the planets' cards named a span their elements hold
over; the 25 moons and the four SBDB dwarf planets gave a source and an epoch, though
BodyRecord.orbitSource is documented as "the span they hold over". And the worst offsets the ETL
stated came from twelve New Year's Days: Nereid's year is 360 days, so all twelve fell far from
its periapsis, where a mean ellipse is furthest out. The one date the ETL checked, 2025-01-01, saw
Nereid at 2.6 degrees; it reaches 11.19.

For each moon and each SBDB dwarf planet the ETL now fetches Horizons' ICRF vectors from 1950 to
2100, every other day (daily for Nereid, at an eccentricity of 0.75, and Hyperion, whose row's
eccentricity is a quarter of its real one: every other day gave it 22.14, daily 22.23), and
measures how far the mean elements stray, at the same TDB dates. The card appends it: "JPL SBDB
osculating elements, epoch 2026 Jun 9, within 7.2 degrees of Horizons from 1950 to 2100". Worst
offsets on the real catalogue: the Moon 2.62 (2010 March 27), Phoebe 2.58 (1969, where a comment
claimed "within 2.0"), Phobos 1.26, Mimas 7.43, Iapetus 10.34, Nereid 11.19 (2039 Nov 1),
Hyperion 22.23 (2055 Feb 26), Ceres 7.12 (1953); Io 0.07, Titan 0.06, Eris 0.06.

build.ts recomputes each from the same Horizons positions and fails if an orbit other than
Standish's names no span, if a card states less than it strays, or if a body passes its ceiling:
3 degrees, and Hyperion 23, Nereid 12, Iapetus 11, Mimas 8 and Ceres 8, each explained. The
2025-01-01 check stays for reading errors, its comment no longer passing one date's offsets off as
worst ones. The Sun's note says the moons' and those four's elements were checked from 1950 to
2100, and the date field's description that each card says how far its orbit strays over that span.
In the running app Ceres's, Phobos's and Nereid's cards end "within 7.2", "1.3" and "11.2 degrees
of Horizons from 1950 to 2100".

The CLOCK_WINDOW comment also had the calendars the wrong way at AD 1: proleptic Gregorian dates
are two days behind the Julian calendar there, level from AD 200 to 300, and ten days ahead by
1582. It now says so, and names Ceres's drift where it named Phobos's, which its orbit now carries.

Controls: the ETL measuring nothing fails ("Ceres's orbit ... names no span it holds over"),
rounding the stated figure down fails on Ceres (7.1 against 7.12), and Nereid held to the general
ceiling fails at 11.19; the note and the date field without the span fail their named tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:57:16 +02:00
SenrokaiandClaude Opus 5.5 2d4b8a98d5 Put each body's photograph on it one frame at a time, so entering the Sun's system no longer stalls
buildMarker gave every photographed body its map at once. A texture is copied to the GPU in the
first frame that draws it, and the 28 maps arrive within about 40 ms of each other, so that frame
copied some 20 megapixels of JPEG (seven maps at 2048x1024) through copyExternalImageToTexture: a
second long task of 135-162 ms about 1.25 s after entering, measured here four times on the
committed renderer (reviewers measured 160-210 against 85-100 without the 18 new maps). de34fff's
"adds no long task" was measured before those maps landed.

A photographed body now starts in its kind's flat colour, as a derived one does, and its texture
waits in a queue; each update() puts the first one that has loaded on its body. The copies are
spread one a frame, and all 38 bodies have their maps within half a second of the first. In the
running app, five fresh entries into the Sun's system at 1600x1000 left one long task of 52-66 ms
or none at all ([66], [52], [62], [], [56] ms, where the committed renderer gave [62, 149], [56,
135], [74, 162], [78, 162]).

Control: putting every loaded photograph on in one frame fails "puts them on their bodies once
loaded, one a frame".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:48:31 +02:00
SenrokaiandClaude Opus 5.5 6c0626ca38 Frame the Sun's system out to Eris on a portrait window, where Eris and Makemake arrived off screen
The arrival framing fits the grid's outer ring, which Eris (a = 67.93 AU) took from 40 AU to 80,
but its 200 AU ceiling was sized for Pluto's ring. At 390 by 844 the ring needs 416 AU and at 1000
by 1400 269, so both were clamped to 200: Eris arrived at NDC (2.08, 0.48) on the phone, with
Makemake at (-1.14, -0.25), and at (1.34, 0.48) on the tall window. The spec never saw it, its
solar system ending at Neptune.

The ceiling is now 500 AU, which frames the 80 AU ring at any aspect down to 0.385; the landscape
fit is unchanged. The window-shape test now includes the solar system out to Eris and a 390 by 844
phone. In the running app every top-level body is on screen on arrival: the camera at 415.8 AU on
390x844, 269.0 on 1000x1400 and 192.1 on 1600x1000.

Control: the ceiling back at 200 fails "leaves the outermost ring clear of the frame edge at every
scale and window shape".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:42:23 +02:00
SenrokaiandClaude Opus 5.5 8c4f1c11c9 Build a body still waiting for its surface with a null map, so three stops warning on each one
Since de34fff paints derived surfaces after the system is built, every body without a photograph
was given map: undefined, and three's Material.setValues warns "parameter 'map' has value of
undefined" for each: eleven warnings every time the Sun's system was entered, one per exoplanet in
any other. The marker now starts with map: null, which three takes without a word and which
the deferred paint replaces as before. In the running app, entering the Sun (38 members) now logs
no console message at all.

Control: undefined again fails "builds a body still waiting for its surface without three warning
of an undefined map".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:35:27 +02:00
SenrokaiandClaude Opus 5.5 036af5f02d Test what the drawn solar system claims at far dates and on Saturn's ring
Three claims had no test that fails without them:

- Standish's rates for a, e and i. The frozen Horizons vectors run from 1950 to 2100, where
  dropping them moves a planet at most 0.036 degrees (Saturn in 2100), inside every ceiling; the
  clock runs to AD 3000, and the long span is what those rates are for. Two vectors from Horizons
  (DE441) for 3000-01-01 now join the table: the Earth-Moon barycentre, 0.005 degrees out (0.129
  without the rates), and Saturn's, 0.065 (0.412).
- A planet's orbit line turned each tick with its node and periapsis: only the Moon's and Pluto's
  were tested. Mars must stay on its own line 730 000 days before J2000; on a line left at J2000 it
  is 3.3 million km from it at AD 1.
- Saturn's ring lit and drawn from both faces, which dc20acc's title claims and the tests, reading
  only its geometry and picking through its front face, never checked.

Controls: the three rates dropped fails "puts earth within 0.02 degrees of Horizons on JD
2816787.5" (and Saturn's); the top-level line left unturned fails "turns a planet's drawn orbit
with its node"; an unlit front-face-only material fails "is lit, and seen from either face".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:33:38 +02:00
SenrokaiandClaude Opus 5.5 56af5e3553 Test the clock where CI could not see it: the date field's time zone, a backwards date, a reopened panel
Three behaviours of the clock had no test that would fail without them:

- "jumps the clock to the date submitted, read as UTC" only told UTC from local time on a machine
  outside UTC. CI runs on ubuntu-latest, in UTC, where both readings are the same instant, so a
  field read as local time passed all 805 tests there. The test now sets TZ to Asia/Kolkata (UTC
  +5:30) itself, and afterEach unstubs it.
- Nothing checked that a negative rate moves the date backwards; the dock's test read only the
  rate's sign. The store now checks that at -86 400 s/s a second of wall clock is a day earlier.
- Nothing checked that reopening the Display panel fills the date field with the clock's date,
  rather than the one it held when the dock was built.

Controls, the suite run under TZ=UTC: the field read as local time fails "jumps the clock to the date
submitted, read as UTC"; the rate's size taken without its sign fails "runs the date backwards at a
negative rate"; toggleTab not refilling the field fails "fills the date field again with the clock's
date when the panel is opened again".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:30:38 +02:00
SenrokaiandClaude Opus 5.5 34084c0eec Test the body page itself: its ring in Saturn's equator, and each body turned for the map's date
No spec mounted BodyDetailSceneComponent, so the two things the page was changed for could be
undone with all 805 tests passing: putting audit #47's 17-degree lean back on the page's ring, and
sending every body back to the slow turn for show instead of bodyPageView. saturnRing's geometry
and bodyPageView were each tested alone; how the page wires them was not.

body-detail-scene.component.spec.ts mounts the page on a stand-in engine and data loader, the
pattern galaxy-system-scene's spec uses, with the page's template cut to its canvas. It opens
Saturn and checks that the ring's face normal, read off its geometry through its world matrix,
lies on the planet's pole within 1e-6 rad; and it opens Earth with the clock pinned to 2025-06-01
12:00 UTC and checks that the sphere and the light are what bodyPageView gives for that date.

Controls: the ring leant 17 degrees fails "lays Saturn's rings in its equator on the page"; the page
falling back to its show spin fails "turns Earth on its page as it stands at the map's date".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:27:06 +02:00
SenrokaiandClaude Opus 5.5 ff7525d3b5 Stop a running clock at the ends of its window, where setDate already refused to go
Only setDate held the clock to AD 1 - AD 3000; julianDate did not, so a month a second carried it
past either end with nothing to stop it. Past AD 3000 it drew the planets on elements Standish
never fitted there, under a note naming "AD 3000"; before AD 1, toISOString writes the six-digit
years ECMA-262 uses outside 0000-9999, and the note, the date strip and the date field, which cut
it at fixed places, read "... to -000001-10-05 20 UTC.", "-000001-10" and an empty field.

julianDate now stops the clock at the end it ran into: re-anchored there, at real time turned back
into the window (forwards at AD 1, backwards at AD 3000), as if the reader had set that date. In
the running app, run backwards from 0001-01-10 at a month a second for 20 s, the note reads "to
0001-01-01 00:00 UTC." and the strip "0001-01-01", the clock 19.7 s into AD 1 at real time; run on
from 2999-12-01 for 8 s, "to 2999-12-31 23:59 UTC." at real time backwards.

Control: julianDate unheld fails "stops a running clock at either end of the window".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:24:29 +02:00
SenrokaiandClaude Opus 5.5 97b8dd9dc8 Turn the Sun about its IAU pole, once in 25.38 days, as every planet already is
Every body with IAU elements was turned by its pole and W, but the Sun, which is the system's star
marker and no BodyRecord, was built with an identity rotation and never touched: its pole pointed
at RA 90, Dec 0, 115.03 degrees from the WGCCRE 2015 solar pole (RA 286.13, Dec 63.87), and it
stood still where its W turns 14.1844 degrees a day.

SUN_ROTATIONAL_ELEMENTS carries NAIF body 10 from pck00011.tpc, and the ETL fails if they are not
the kernel's. The scene turns the star marker by bodyOrientation each tick when the star is the
Sun, as the renderer turns the planets. In the running app the Sun's drawn pole lies on the IAU's
(0.00 degrees) and its map turns 14.1844 degrees between 2026-01-01 and 01-02. The map's longitudes
are Solar System Scope's, not Carrington's, so the phase of W is not the Sun's own; the pole and
the rate are.

Controls: leaving the marker unturned fails "turns the Sun about its IAU pole, once in 25.38 days";
the app's elements off the kernel's (W rate 14.18) fails the ETL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:21:15 +02:00
SenrokaiandClaude Opus 5.5 8e3a494fe9 Print Hyperion's eccentricity as JPL measures it now, 0.105, not the archived row's 0.023
The card listed Hyperion's eccentricity under "Measured" as 0.023: the archived JPL satellite row
the orbit is drawn from gives 0.0232. JPL's current table (SAT441) gives 0.105, and Horizons'
osculating orbit ranges 0.074 to 0.132 from 1980 to 2100 (0.1099 on 2025-01-01). The row stays
the orbit: with 0.105 put into it, Hyperion is further from Horizons, not nearer (median 9.6
degrees against 7.8 over 1980-2100, as a reviewer measured), so only the card changes.

BodyRecord.measuredEccentricity carries the figure the card prints where it is not the orbit's
own; the ETL sets it for Hyperion, and buildBodyViewModel prints it. build.ts now checks every
card's eccentricity against Horizons' osculating one on 2025-01-01, within 0.03: measured at most
0.0151 (Phoebe, and the Moon, whose eccentricity swings) once Hyperion prints 0.105, where the
row put it 0.0867 out. The live Hyperion page reads "ECCENTRICITY 0.105".

Controls: the ETL with Hyperion on its row's figure fails ("Hyperion's card gives an eccentricity of
0.0232, where Horizons' osculating orbit has 0.1099"); the view model ignoring the field fails
"prints the eccentricity measured for a moon whose orbit keeps an older one".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:17:06 +02:00
SenrokaiandClaude Opus 5.5 f655a4c0f7 Turn Eris once in 15.77 days, locked to Dysnomia, not in the 25.9 hours the SBDB flags as unreliable
Eris took its day from the SBDB's rot_per, 25.9 hours, whose own note reads "Result based on less
than full coverage, so that the period may be wrong by 30 percent or so" (Roe et al. 2008). Eris
is locked to Dysnomia: its light curve repeats every 15.771 +/- 0.008 days (Bernstein et al. 2023,
PSJ 4, 115), Dysnomia's 15.78590-day orbit (Holler et al. 2021; Szakáts et al. 2023, A&A 669, L3).
It was drawn turning 14.6 times too fast.

Eris's BodySpec now carries that day, 378.504 hours, cited as its radius already cites Sicardy et
al., and a spec's measured day comes before its source's. build.ts checks that Eris's day is
Dysnomia's orbit within 0.2 per cent. In the running app Eris turns 5.707 degrees in six hours, as
15.771 days gives; on 25.9 hours it turned 83.4.

Makemake's SBDB period, 22.83 hours, carries the same flag; it is Hromakina et al. 2019's own
result and nothing later overturns it, so it is kept.

Control: Eris on the SBDB's period fails the ETL: "Eris turns once in 1.079 days".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:14:20 +02:00
SenrokaiandClaude Opus 5.5 86d97b903e Print Earth's inclination as 0.00 degrees, not -0.00
Standish's Table 2a fits the Earth-Moon barycentre's inclination as -0.00054346 degrees, and the
card printed toFixed(2) of it: "Inclination -0.00°", where the branch's base read 0.00. A negative
inclination is the same orbit as its size with the node turned half round, so the card prints the
size. The elements the map propagates are left as Standish gives them. The live Earth page now
reads "INCLINATION 0.00°".

Control: printing the fitted sign again fails "prints the size of an inclination fitted below
zero".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:11:56 +02:00
SenrokaiandClaude Opus 5.5 395b613f82 Stop telling the reader that the moons drawn without a map were never imaged
provenanceFor ended every derived surface with "Not an observation — no image of this world
exists", a sentence written for exoplanets. The branch added eleven solar-system bodies with no
map in the catalogue, and eight of them are moons spacecraft photographed: Voyager 2 imaged
Miranda, Ariel, Umbriel, Titania, Oberon, Proteus and Nereid, Cassini Hyperion (26 Sep 2005, from
about 500 km). The textures README says so itself. Hubble sees Eris, Haumea and Makemake too, as
points.

Only an exoplanet now gets that sentence. A moon or dwarf planet drawn from its measurements says
"no global map of this world is used here", which is true of all eleven. Read off the live pages:
Titania, Hyperion and Eris end with it, and an exoplanet keeps the old wording.

Controls: giving every derived surface the exoplanets' sentence fails "says a moon without a map is
illustrated, without saying it was never imaged"; giving it to none fails "says an exoplanet has
never been imaged".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:10:35 +02:00
SenrokaiandClaude Opus 5.5 87e9ec274b Turn Venus's map north up, so Maxwell Montes is drawn in the north where the IAU puts it
venus.jpg, from the Solar System Scope pack, is the Magellan radar map turned half round: south up
and east to the left. Its brightest feature north or south of 50 degrees, Maxwell Montes, sat at
63.4 S, 8.9 W (blurred at sigma 3), with Lakshmi Planum east of it; the IAU Gazetteer puts Maxwell
at 65.2 N, 3.3 E, at Lakshmi's eastern end. The IAU pole and W are right (Venus's sub-Earth
longitude matched Horizons to the thousandth), and so is MAP_TO_BODY, which Earth, Mars, the Moon
and Mercury were checked against; the file was not, and its surface was drawn turned 180 degrees
about the prime meridian's axis.

Turned back with PIL's ROTATE_180 and re-saved on the file's own quantisation tables (0.03 grey
levels from the exact turn, 240 079 bytes), its brightest point is 63.7 N, 8.3 E with Lakshmi to
the west, and the dev server serves that file. The textures README records the check, the
MAP_TO_BODY comment adds Venus to the maps it names, and texture-catalog.spec.ts pins the checked
file's SHA-256, since no image decoder runs in the unit suite (a triple-slash reference gives that
one spec Node's types).

Control: the pack's file put back fails "wraps Venus in the map turned north up".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:07:03 +02:00
SenrokaiandClaude Opus 5.5 34ae803c06 Take TT - UT from the historical record before 1972, so the far dates the clock reaches turn every body by the right amount
The clock reaches AD 1, but TT - UT was held at today's 69.184 s. At AD 1000 it was 1 574 s and at
AD 1 about 10 570 (Espenak and Meeus, NASA's Five Millennium Canon; Horizons' TDB - UT gives 1 658
and 10 466 on JD 2086455 and 1721600). So every spin but Earth's was (ΔT - 69 s) times its rate
out, Jupiter 15.2 degrees at AD 1000 and 106 at AD 1, Mars 6 and 43, and every orbit that much
behind: the Moon about 0.2 and 1.4 degrees.

ttMinusUtSeconds gives TT - UT for a date on the clock: the Espenak-Meeus polynomials before 1972,
32.184 s plus UTC's leap seconds from 1972 to the last one, at the start of 2017, and 69.184 s
held after it, as Horizons holds it. Its pieces join within 0.1 s. tdbFromUtc, which positions
and spins already share, now adds it. Within 0.2 s of Horizons in 1950, 105 s at AD 1 and 86 s
at AD 1000, where the historical record itself is that uncertain.

Earth is the exception: its turning is what UT counts, so the clock's date already says how far it
has turned, and ΔT would turn it again, 44 degrees at AD 1. Its W, fitted to today, keeps today's
69.184 s (bodyOrientation's followsUt, set for Earth in the system view and on its page).

In the running app at 1000-01-01 00:00 UT, Jupiter's drawn prime meridian sits 0.000 degrees from
its IAU W at TT and 15.164 from where the held offset put it; Earth's sits on its W at UT + 69.184 s,
6.288 degrees short of what TT would have turned it to.

The renderer spec now hands its frozen Horizons vectors over as the UT dates that name them
through the same TT - UT, and checks Jupiter's and Earth's prime meridians at AD 1000.

Controls: the leap-second rule used before 1972 fails "follows the historical record before 1972";
TT - UT held at 69 s fails "turns Jupiter at AD 1000 by its W"; Earth turned at TDB, or the renderer
or the page not keeping it on UT, fails the Earth tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:03:26 +02:00
SenrokaiandClaude Opus 5.5 d4808788ec Take the orbits at TDB as the spins already were, so a locked moon faces the planet it is drawn round
Every element set here runs on TDB: Standish's T_eph, the SSD satellite and SBDB epochs, the
IAU's d and T. bodyOrientation already took the clock's UTC to TDB, but SystemOrbitsRenderer.update
and the body page's heliocentricPosition fed the UTC date straight to meanElementsAt, so in one
frame each body's place was 69.184 s behind its spin. That is n x 69 s of orbit: Phobos 0.90
degrees, Mimas 0.31, Deimos 0.23, Enceladus 0.21, Miranda 0.20, Io 0.16, Tethys 0.15, Europa
0.08, the Moon 0.011. 48319c3's table measured the app at a UTC date against Horizons at the same
number read as TDB, which hid it, and its "nothing for anything else" was wrong: Io's 0.16 is four
to five times Io's worst model error there (0.035).

tdbFromUtc, in constants.ts, is now the one conversion, and positions and spins both go through
it. In the running app, clock pinned to 2025-06-01 12:00 UTC, Io's face towards Jupiter is at
0.024 E, latitude -0.009, where Horizons (observer quantity 14 from Jupiter's centre) has 0.036 E
and -0.003: 0.012 degrees apart, where it was 0.175. The renderer spec checks that point, and now
hands its frozen Horizons vectors, which are TDB, to update() as the UTC dates that name them,
69.184 s earlier; the same frozen rows fed at the UTC date fail for Io and Europa. A body-page test
checks that the Sun lights the point it stands over at the same TDB instant the body is turned for.

Controls: taking the renderer's orbits at the clock's UTC fails "faces jupiter and the Sun with the
points Horizons gives on io"; taking the page's Sun there fails "takes the Sun where it stands at
the same TDB instant".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 18:45:26 +02:00
SenrokaiandClaude Opus 5.5 f8ee3ac3ab Move Mimas, Tethys and Phobos along their orbits by the terms their IAU W already carried
The IAU W of a locked moon follows its mean longitude, so a term of W that is the moon running
ahead of and behind its mean motion is its orbit's too. Two were in bodies.json's W and in no
orbit: the 71-year libration of the Mimas-Tethys 4:2 resonance, -44.85 degrees on Mimas and
+2.23 on Tethys on the angle S5 = 316.45 + 506.2 T of pck00011.tpc, and Phobos's tidal
quadratic, 9.536e-9 degrees a day squared about J2000. JPL's satellite table has a column for
neither. orbitalTermsOfPrimeMeridian now turns each into the row's meanAnomalyTerms about the
row's own epoch (Phobos's 1950 row gets the quadratic re-centred, which adds to its mean motion
and mean anomaly at the epoch), and the ETL takes them for the three moons named in their specs.

Against Horizons: Mimas on 2026 May 27, near the libration's extreme, 2.24 degrees instead of
43.3; Tethys the same day 0.18 instead of 2.05; Phobos in 2100 1.25 instead of 11.1. On the
ETL's 2025-01-01 check Mimas is 1.56 degrees, so its named 46-degree ceiling is gone. The renderer
spec freezes the Mimas and Phobos vectors.

The day-equals-orbit check checked a number that turns no locked moon: since cdf474b every one is
turned by its IAU W. build.ts now checks what is drawn instead: the east longitude of the planet
on the moon's IAU body-fixed frame, from where the mean elements put it, every 135 days from 1950
to 2100. Measured: at most 6.70 degrees (the Moon's own eccentricity swing), with Mimas 10.15
(its physical libration, which Horizons shows too), Iapetus 18.33 (its row sits 9.4 degrees
behind Horizons) and Proteus 8.18 under their own ceilings. The lock holds only over that span:
Proteus's W turns 6.3e-7 of its rate slower than its orbit, which drifts its face 74 degrees by
AD 3000, and Mimas's W runs 6e-5 degrees a day ahead of the table's mean motion. Without the new
terms the check fails: Phobos's face turned 13.78 degrees from Mars by 2100, and Mimas's 54.5.

The comments that promised the lock "however long the clock runs" now say what the check covers.
The ceilings comment in build.ts gives Hyperion's and Nereid's worst offsets sampled daily (22.2
and 11.2 degrees, where twelve New Year's Days gave 20.2 and 2.6).

Controls: dropping the libration's cosine term, the quadratic, the quadratic's re-centring or
swapping sine and cosine each fails its named test; the ETL without Mimas's term fails at 44.68
degrees and without Phobos's at 13.78.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 18:37:58 +02:00
SenrokaiandClaude Opus 5.5 db3af1a820 Wrap Deimos in Stooke's Viking map, once its longitudes were settled on the body
Audit #40. deimos.jpg was a 592x592 disc photograph, 32.6% black sky, left in the folder
unlisted by 302fa96 because the one cylindrical map found then (USGS
wms_basemaps/Deimos/deimoscyl4.jpg) gave no way to tell which way its longitudes ran. It is now
Stooke's later map of the same set, from the NASA PDS Small Bodies Node
(MULTI-SA-MULTI-6-STOOKEMAPS-V3.0, deimos_cyl_viking_mro.jpg): Viking Orbiter images with MRO
HiRISE detail, 7200x3600 simple cylindrical, "0 longitude at the center".

The guide does not say which way longitude runs, and USGS georeferences its copy of the older
version with 0 at the left edge. Settled on the body: read with 0 in the middle and east to the
right, and drawn as a globe from outside, the hemisphere at 90 E matches unmirrored the sheet
Stooke titles "trailing side" (270 W), and the one at 90 W his "leading side". A synchronous
prograde moon trails at 90 E, so that reading holds; read the USGS way, the sheets would land on
the wrong hemispheres. A 1 km depression sits at Swift's Gazetteer position (12.5 N, 1.8 E).

Processed like the other maps (build_maps.py): no no-data pixels to grey (13 source pixels of 26
million at 0), area-downsampled to 1024x512, JPEG q85, 55 KB. Black pixels (under 8 of 255): 0%.
The map wraps seamlessly (mean 1.8 grey levels across the seam).

Measured in the running app (port 4311) through the IAU rotation and MAP_TO_BODY: Mars stands
over 0.18 W, 0.41 W and 0.20 W of the drawn map (latitudes within 0.04 deg) on 2000-01-01.5,
2025-01-01 and 2050-01-01, and Deimos heads toward 90.08 W, 90.30 W and 90.11 W: the hemisphere
that matched Stooke's leading sheet leads.

texture-catalog.spec.ts now lists Deimos among the mapped bodies. Mutant: the deimos line removed
from BODY_TEXTURE_PATHS; only 'wraps the moons and dwarf planets that have a mission mosaic in it'
failed (1 of 805). The textures README gives the source, credit, licence (the PDS archive states no
use restriction) and the longitude check; the root README counts twenty-eight photographed bodies.
The five Uranian moons stay derived: USGS has only Voyager control networks for them, no mosaic.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 13:29:22 +02:00
SenrokaiandClaude Opus 5.5 ac6a3bb1ea Let the reader set the clock to a date, and run it backwards
The clock from #33 could only run forwards from now. The Display panel's
clock now has a Date (UTC) field, a native datetime-local in a form, so Enter
submits it and the browser holds it to its min and max. It jumps the clock to
that date, and the clock carries on from there at the rate it was running at.
A Backwards toggle (aria-pressed) runs the same four rates the other way. The
radios still pick the rate's size and keep the direction when it changes.

The window is AD 1 to AD 3000. The end is where Standish's Table 2 stops
being fitted (3000 BC to AD 3000; every planet within 0.29 degrees of
Horizons at each date measured out to 3000). The start is the date input's
own floor. TimeStore.setDate refuses anything outside it, and NaN, and leaves
the clock where it was. The field is read as UTC. Its dates are proleptic
Gregorian, as a Date is, so before 1582 they run up to ten days ahead of the
Julian-calendar dates history gives. The window is written beside
CLOCK_WINDOW, with the moons' shorter reach (Phobos 11 degrees out by 2100).

The system note now names the date it is drawn for, to the minute:
"... to 2020-12-21 18:00 UTC.", or "to now, <date> UTC." at the present.

Measured in the app (port 4311, keyboard only: fill, Enter):
- Set to 2020-12-21 18:00 UTC, Jupiter and Saturn seen from Earth's drawn
  position are 0.113 degrees apart. Horizons gives 0.102 geocentric
  (geometric 0.1017, astrometric 0.1018). Distances: 5.9267 and 10.8296 AU
  against Horizons' 5.9258 and 10.8270.
- 3001-06-01 is refused by the form (validity false) and the clock does not
  move.
- Backwards at 1 d/s: -2.011 days in about 2 s.
- The field's accessible name is "Date (UTC)" and its description is the
  window. Its colour-scheme is dark, so the picker icon shows on the HUD.
- Back to now puts the field back on the present too.

Unit suite 799 -> 805: two tests for the store, three for the dock, one for
the scene note. Eleven guarded mutants; each changed its file and made its
named test fail. Among them: the window check dropped, the wall clock not
re-anchored on a jump, the radio dropping the direction, the field read as
local time, and the note not naming the date.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:05:50 +02:00
SenrokaiandClaude Opus 5.5 dc20accfdf Draw Saturn's rings in the system view, lit, at the radii their texture draws
Audit #47. The body page's rings already lie in Saturn's equator (76fb386),
but the system view drew Saturn as a bare sphere, and the page sized the rings
to 1.4-2.6 planet radii regardless of what saturn_ring.png draws where.

The strip runs straight out from its left edge to its right. Read off its
alpha, the C ring's inner edge (74 490 km) is at px 91 of 1 280, the B ring's
inner and outer edges (92 000 and 117 580 km) at 404.5 and 860, the A ring's
outer edge (136 775 km) at 1 204 and the F ring (140 180 km) at 1 267.5: one
scale of 55.9 km a pixel fits all five within 1.8 px, so the strip spans
69 400 to 141 000 km. Only the Cassini Division's outer edge misses, drawn
30 px (1 700 km) too far in. Sized to the brief's 74 500 and 140 220 km (the C
ring's inner edge and the F ring) instead, the B ring's inner edge would sit
3 300 km out.

saturnRing (texture-catalog.ts) now builds the rings for both views: flat in
the XZ plane of a sphere built round +Y, sized against the planet as drawn,
MeshStandardMaterial lit from both faces, the strip's own alpha as opacity
(the page used the texture as its own alphaMap too, multiplying its alpha by
its green channel, at 0.85 opacity). In the system view the ring is a child of
Saturn's marker, so the IAU pole turns it into the equator and the pixel floor
scales it with the planet; a ray through it picks Saturn (memberForObject
accepts a marker's child). On the page it now reaches 2.42 radii, not 2.6.
Jupiter's, Uranus's and Neptune's rings are left out: dark, narrow or dusty,
too faint to see at any scale drawn here.

Measured in the running app, the ring's opening to Earth against Horizons'
sub-Earth latitude on Saturn (planetodetic, taken back to planetocentric with
f = 0.09796): 26.963 against 26.966 degrees on 2017-10-16, 0.075 against
0.042 on 2025-03-23, the plane crossing, and -7.764 against -7.813 on
2026-09-24. The Sun stood 26.64, 0.70 and -7.50 degrees above the ring plane
on those dates. At the closest the system view allows, 0.05 AU, Saturn is
about 5 px in radius and its rings reach about 12 px; on the plane-crossing
date they vanish edge-on.

Tests: the three openings against frozen Horizons values and a pick through
the B ring (system-orbits-renderer.spec.ts); the rings' extent, their lying
in the sphere's equator, and the strip sampled outwards so its B ring starts
at 92 000 km and its A ring ends at 136 775 (texture-catalog.spec.ts). Unit
suite 792 -> 799 tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:42:48 +02:00
SenrokaiandClaude Opus 5.5 302fa963ad Wrap seventeen moons and dwarf planets in the missions' own maps, grey where no probe looked
Audit #40. Io, Titan and Pluto had square disc photographs (40.5, 42.6 and
42.9% black sky) that PR #33 unlisted, and every other moon or dwarf planet
fell through to the derived surface. Seventeen of them are now wrapped in
public-domain global mosaics from USGS Astrogeology and the NASA PDS: Phobos
(Viking), Io, Europa, Ganymede, Callisto (Galileo and Voyager), Mimas,
Enceladus, Tethys, Dione, Rhea, Titan, Iapetus, Phoebe (Cassini), Triton
(Voyager 2), Ceres (Dawn), Pluto and Charon (New Horizons). io.jpg, titan.jpg
and pluto.jpg are replaced by maps under the same names.

Every file is simple cylindrical over 360 by 180 degrees, with longitude 0 in
the middle and east to the right, the frame MAP_TO_BODY puts on the IAU body
frame. Processing: the source's no-data pixels (0 in every band) become one
flat grey, the mean of the mapped surface, never invented terrain; area
downsampling to 2048x1024 for bodies over 1 000 km in radius and 1024x512
for the rest; half a turn where the source is centred on 180; JPEG q85
(Europa q82). Largest file 386 KB (Europa); 3.5 MB for all seventeen.

The centre was read from each GeoTIFF's central meridian and left-edge tie
point, not from its label: Rhea's and Enceladus's labels say CENTER_LONGITUDE
= 180 over images centred on 0. Taken from the label, Rhea came out half a
turn round, which the seam it left down the middle of the map gave away.
Each map was then checked by eye against the IAU Gazetteer: Pele and Loki on
Io, Pwyll on Europa, Osiris and Tros on Ganymede, Valhalla and Asgard on
Callisto, Herschel on Mimas, Ali Baba and Aladdin on Enceladus, Odysseus on
Tethys, Creusa on Dione, Inktomi on Rhea, Xanadu, Shangri-La and Belet on
Titan, Cassini Regio on Iapetus, Jason on Phoebe, Occator and Haulani on
Ceres, Stickney on Phobos, Sputnik Planitia and Cthulhu on Pluto, Mordor
Macula on Charon, Leviathan Patera on Triton.

Unmapped share, now grey: Triton 38.6%, Charon 34.0%, Pluto 31.9%, Phoebe
20.4%, the Galilean polar gaps 3.6-4.3%, Ceres's south pole 3.6%, the rest
under 0.2%. Pixels darker than 8 of 255: at most 0.55% (Charon's Mordor
Macula, Pluto's Cthulhu), against the 20-43% black sky of the photographs
PR #33 dropped.

Left out, and said so in src/assets/textures/README.md: Deimos, whose only
cylindrical map (Stooke, Viking) has no label for its longitude direction and
on which neither Voltaire nor Swift could be found to settle it; the five
Uranian moons, whose only maps (Schenk 2020, USRA) carry no licence; Hyperion,
Nereid, Proteus, Eris, Haumea and Makemake, which have no photographic
simple-cylindrical map.

Source URLs, credits, licences, processing and each measurement are in the new
src/assets/textures/README.md; the root README now points there and counts
twenty-seven bodies in real photography. texture-catalog.spec.ts checks that
the seventeen are registered and that Deimos, the Uranian moons, Hyperion and
Eris are not. Unit suite 790 -> 792 tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:41:28 +02:00
SenrokaiandClaude Opus 5.5 76fb38665f Show each body on its page turned as it is at the map's date, under its real Sun
The body page used to spin every body about its pole at 0.08 radians a second, under a light at
(4, 3, 5) whatever the date. A body with IAU elements is now drawn as it is at the map's clock,
using the same pole, prime meridian and map convention as the system view. It is shown pole up
under a Sun held at the light's old azimuth, so the camera still opens on the day side. The Sun's
height above the equator is the real one, and so is the face it lights. The clock's rate now
turns the page too: at 1 h/s Earth's sub-solar point moved 15.17 degrees in the 1.012 h of sky
one wall second carried. What the page gives up is the stars, which do not turn with the body.

bodyPageView in src/app/shared/rendering/body-orientation.ts takes the Sun's direction from where
the body is: a planet's own mean elements, a moon's planet's place plus its own offset. It sets
the sphere's rotation and the light's direction. Exoplanets, Eris, Haumea and Makemake keep the
old slow turn and light.

Saturn's rings now lie flat in its equator, the page's horizontal. They used to lean 17 degrees,
which put them out of the plane they orbit in.

Measured:
- Live app, clock pinned to 2025-06-01 12:00 UTC: the Sun stands over 0.433 W, 22.125 N on
  Earth's page, the same point as on its sphere in the system view.
- Unit test, raycast on the page's own sphere: Earth one light-time earlier is 0.09 degrees from
  Horizons' sub-solar longitude. Its latitude, put on the flattened Earth, is within 0.03.
- The Moon's sub-solar point is within 0.004 of Horizons'.
Three mutants each fail their named test: a moon lit as if it had no planet; the Sun not held at
the page's azimuth; the body left in the ICRF instead of the page's frame.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 22:50:26 +02:00
SenrokaiandClaude Opus 5.5 cdf474bcd5 Turn every body in the system view by its IAU pole and prime meridian, so the lit face is the real one
Until now each body's axis was its orbit normal, tipped by the obliquity about the orbit's node,
an azimuth the data never gave. Its phase started at an arbitrary point at the elements' epoch.
The rate and the sense were real; the face towards the Sun was not. Now each of the 33 bodies with
IAU elements is set, every tick, from its pole and its W at the clock's date. Eris, Haumea and
Makemake keep the old fallback: their published period, about their orbit normal. None of them
has an obliquity, so the tilt code that only served bodies now turned by the IAU is gone.
Exoplanets have no rotation published and stay still, as before.

The texture convention is settled once, in src/app/shared/rendering/body-orientation.ts (MAP_TO_BODY):
- SphereGeometry runs u eastward about +Y from a seam on -X, so u = 0.5 faces +X.
- Every photograph in the catalogue is centred on longitude 0 with east to the right. Checked on
  the maps: Greenwich; Olympus Mons 134 degrees left of centre; Mare Crisium right and Mare
  Orientale left; Kuiper just left.
- A map labelled in west longitude is still drawn east-right, so where longitude 0 sits is the
  only question, and for all of them it is the centre.
- So a quarter turn about X puts the map on the IAU body frame: pole +Z, prime meridian +X.

The scene is already ICRF equatorial (the ecliptic is turned into it by the J2000 obliquity), so
the pole goes in as it is. The equator frame is built through laplacePlaneToEquatorial, the same
conversion the moons' Laplace planes use; moonFrame now calls it too. The clock is UTC and the
elements TDB, so TT - UTC (69.184 s) is added: Earth turns 0.29 degrees in that time, Jupiter 0.70
and Phobos 0.90.

Measured on the live app (port 4311), clock pinned to 2025-06-01 12:00 UTC:
- The Sun stands over 0.433 W, 22.125 N on Earth's drawn sphere. The equation of time puts it at
  0.53 W.
- Each body was drawn one light-time earlier and compared with Horizons' observer quantities 14
  and 15:
  - Earth (from the Sun): longitude 0.095 off.
  - Mars: sub-Earth 0.001, sub-solar 0.004.
  - Jupiter: sub-Earth 0.005, sub-solar 0.002.
  - The Moon: sub-solar 0.004; sub-Earth 0.699, which is the error of its mean orbit.
- Horizons' latitudes are planetodetic. Raw, they differ by the flattening: Earth 0.14, Mars
  0.23-0.27, Jupiter 0.33, the Moon (a sphere) 0.000.

The unit tests put the same comparison through real raycasts on the drawn spheres' texture
coordinates, with the latitudes put on each body's flattened figure. Every residual is within
0.09 degrees, but for the Moon's sub-Earth point (0.70 and 0.09).

The retrograde tests of #33 are rewritten for the IAU's convention: a planet's named pole is
the one on the north side, so W runs backwards for Venus and Uranus, while Pluto follows the
right-hand rule. The spin read off the drawn sphere, against the drawn orbit's normal, is 177.36
for Venus, 97.77 for Uranus and 119.61 for Pluto, all past 90, and 23.44 for Earth. Each is
within 0.5 of Horizons.

Six mutants, each failing its named test: the map upside down; UTC taken for TDB (Jupiter's
test); W turned the wrong way (the retrograde test, and again Earth's noon test); moons, or
planets, not turned by the IAU; and the fallback ignoring a negative period.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 22:48:05 +02:00
SenrokaiandClaude Opus 5.5 1c86584642 Carry every body's IAU rotational elements, read from NAIF's kernel of the 2015 report
bodies.json now holds, for 33 of the 38 bodies, the pole right ascension and declination and the
prime meridian W of the IAU WGCCRE 2015 report (Archinal et al. 2018), with their rates and the
periodic terms. They are read from NAIF's pck00011.tpc, which carries the report in a form a
program can read, periodic terms and their angles included. Hyperion (chaotic), Nereid, Eris,
Haumea and Makemake have no model in the report.

The parser, src/app/shared/astro/rotational-elements.ts, sits beside the other source readers so
the unit suite covers it. It reads data blocks only where \begindata stands alone on a line, as
the kernel's own prose mentions the token mid-sentence. It reads the Fortran exponent (the Moon's
-1.4D-12 d² term) and the degree-2 angles of the Mars system, where Phobos's tidal acceleration
lives. NAIF numbers a small body 2 000 000 past its catalogue number, so Ceres is 2000001.

Periodic terms under 0.01 degrees are left out. 0.01 degrees moves a point by 0.11 px on the
largest body ever drawn (Jupiter at 641 px of radius). That drops 32 terms:
- Mercury: 4 (0.0011 degrees and less)
- the Moon: 8 of 13 (0.0072 and less)
- Mars: 13 (0.00024 and less); its three 0.42-1.59 degree long-period terms stay
- Phobos: 1 (0.0063)
- Jupiter: 5 (0.0022 and less)
- Europa: 1 (0.009)
Kept, among others: Mimas's 44.85-degree libration, Triton's 32-degree precession, Miranda's 4.4
and Phobos's 1.14-degree libration.

build.ts now checks the elements against Horizons on the real catalogue:
- Every body but those five carries elements, and they do not.
- The IAU day, 360 over W's rate, is within 1e-4 of Horizons' period. Measured: at most 1.8e-5
  (Jupiter). Neptune gets a 0.01 ceiling: 0.89 per cent, because the report takes Karkoschka's
  15.9663 h where Horizons keeps Voyager's 16.11.
- The spin axis, the pole turned end for end where W runs backwards, is within 0.1 degrees of
  Horizons' obliquity. Measured: at most 0.058 (Venus, 177.358 against 177.3); Uranus 97.771,
  Pluto 119.610, Earth 23.435.
Full npm run etl passes. Three mutants each fail it on the named check:
- W's sign dropped: "Venus's IAU spin axis is 2.642 degrees".
- Ceres looked up by catalogue number: "Body ceres has no IAU rotational elements".
- W's rate read per century: "Mercury's IAU day ... 3.65e+4".

Nothing is drawn from these yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 22:46:14 +02:00
SenrokaiandClaude Opus 5.5 ab7d454db1 Read Mercury's obliquity in the arcminutes its Horizons page gives it in
Mercury's page states "Obliquity to orbit[1] = 2.11' +/- 0.1'", in arcminutes, where every other
page writes degrees. The pattern took the number alone, so bodies.json had Mercury tilted 2.11
degrees, sixty times too far, and the system view drew it that way. It now reads the arcminute
mark and divides by 60: 0.0352 degrees, against the 0.034 the IAU's pole for Mercury makes with
its orbit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 22:09:36 +02:00
SenrokaiandClaude Opus 5.5 1d42be2ad5 Add Charon, the moons of Uranus, Saturn's other large moons and the four dwarf planets past Pluto's table
The solar system stopped at 18 bodies: Pluto without Charon, Uranus without a moon, Saturn with
Titan alone, no dwarf planet but Pluto (audit #22). bodies.json now holds 38: the eight planets,
the five IAU dwarf planets, and every moon in JPL's mean-element table more than 100 km in mean
radius. New: Ceres, Eris, Haumea, Makemake; Mimas, Enceladus, Tethys, Dione, Rhea, Hyperion,
Iapetus, Phoebe; Miranda, Ariel, Umbriel, Titania, Oberon; Nereid, Proteus; Charon. Search finds
each by name (it indexes bodies.json), each has a body page, and the Sun's system draws them.

Where they come from
- Moons: the same archived JPL satellite table as the others. Uranus's and Pluto's are given
  against the planet's equator, with the IAU WGCCRE 2015 poles: Pluto's as the IAU gives it
  (132.993, -6.163), Uranus's at the end the table measures inclinations from (77.311, 15.175)
  with its nodes counted 180 degrees on, from the IAU pole's crossing; read without that offset
  every Uranian moon was 180 degrees from Horizons at every date from 1980 to 2100.
- Two rows are corrected where they disagree with JPL's own ephemeris and the reason is known.
  Pluto's section prints epoch 2000 Jan 1.0; JPL's current table gives Charon's as 2000-01-01.5,
  and at 1.0 Charon was 27.8-28.2 degrees from Horizons at every date, half a day of its motion.
  Phoebe's mean motion gives 548.02 days where its Horizons page and the current table give
  550.30 (the table's own note says its source misstated retrograde moons' mean motions); on the
  row's figure Phoebe was 24.6 degrees out by 2025 and 100 by 2075.
- Dwarf planets: JPL SBDB osculating heliocentric elements with their epoch (2026 Jun 9), carried
  at their own n. Against Horizons (heliocentric, 1950-2300; the clock only runs forward from now):
  Ceres 0.02 degrees in 2025, 1.9 in 2050, 4.0 in 2075, 5.3 in 2100, 11.6 in 2200 (Jupiter pulls
  on it and nothing here carries that); Eris within 0.06 to 2100 and 0.5 to 2300; Haumea within
  0.35 to 2100; Makemake within 0.25 to 2100 and 1.7 by 2200.
- Size and spin: Horizons pages for the moons (Charon 606 km, Miranda 235.7 as the mean of its
  three axes). The SBDB for Ceres (469.7 km, 9.074 h) and for the other three's spins (Eris 25.9 h,
  Haumea 3.915 h, Makemake 22.83 h). Neither source nor the WGCCRE 2015 report has a radius for
  Eris, Haumea or Makemake, so each carries its stellar-occultation measurement: Eris 1163 km
  (Sicardy et al. 2011), Makemake 715 (Brown 2013, the mean of 1434 x 1434 x 1422 km), and
  Haumea 797.6, the radius of a sphere of its volume: it is triaxial, 1161 x 852 x 513 km
  (Ortiz et al. 2017), and is drawn as that sphere.
- Rotation uses the branch's model. Every moon is locked except three: Hyperion's page says
  "Chaotic" and Nereid's gives no spin, so both are left still; Phoebe turns in 9.274 h.
- Charon carries massRatio 0.12205, the GM ratio of the two Horizons pages (106.10 / 869.326), so
  it and Pluto are drawn round their barycentre 2 131 km from Pluto's centre.

Validators (tools/etl/build.ts, on the real catalogue; full npm run etl passes)
- Offsets from Horizons on 2025-01-01, new bodies: dwarf planets at most 0.016 degrees (Ceres),
  under the 0.25 ceiling; moons Dione 0.009, Ariel 0.058, Rhea 0.070, Charon 0.111, Oberon 0.142,
  Titania 0.185, Umbriel 0.219, Proteus 0.245, Enceladus 0.309, Phoebe 0.984, Miranda 1.162,
  Tethys 2.042, under the 2.5 ceiling, which is unchanged.
- Four moons get their own ceiling, each just above its worst offset at twelve dates from 1980
  to 2100 and each named with its reason: Mimas 46 (measured up to 44.7: its resonance with
  Tethys swings its longitude 44 degrees either way over 70.8 years, which the table has no
  column for), Hyperion 21 (20.2; held in resonance by Titan, and the row's eccentricity 0.0232
  is under a quarter of the current table's 0.105), Iapetus 11 (10.1; the row sits 9.4 degrees
  behind Horizons at its own epoch and keeps that, with its plane within 0.07 degrees and its
  period within 0.001 per cent), Nereid 3 (2.6 in 2025; eccentricity 0.75).
- New checks: every body has a radius over 0 (Charon's would have been 0 before the page
  parser learnt its form); a freely spinning moon is not locked; a moon with a mass ratio puts
  the barycentre outside its planet; there are 5 dwarf planets.
- Negative controls, each a full npm run etl on the real catalogue refused with the named
  message: Uranus's node offset removed (Miranda 172.50 degrees), Charon at the printed epoch
  (28.08), Phoebe on the row's mean motion (24.61), Charon's radius unread (no radius),
  free spinners locked (Hyperion), mass ratio inverted (barycentre 17 460 km out).

Measured in the running app (port 4311): the Sun's system has 38 members ("13 + 25 moons");
Charon comes back to within 0.0004 degrees of where it started after 6.38723 days and is 179.98
degrees round after half that; Pluto is 2 130.6 km from the barycentre and Charon 17 456.8,
exactly opposite; Saturn's moons in order of distance now: Mimas 185 617 km, Enceladus 238 042,
Tethys 294 648, Dione 376 805, Rhea 526 964, Titan 1 231 389, Hyperion 1 470 453, Iapetus
3 637 059, Phoebe 11 740 900. At the arrival framing the dwarf planets are held at the 3 px
floor and the moons at 1.5 px, half their planet's drawn radius, the scene's existing rule.
Searching Charon, Enceladus, Ceres, Titania, Makemake and Phoebe each finds the body; the body
pages show Charon 6.39 d and 606 km, Titania 8.71 d, Ceres 4.6 yr and 470 km, Haumea 283 yr and
798 km, Hyperion 21.3 d, each with its orbit source. Long tasks on entering: see the previous
commit.

The Sun's note now says the four dwarf planets are on the SBDB's osculating elements. Holding
Eris's orbit, the arrival framing widens: 192 AU of range on a 1600 x 1000 window, under the
200 AU ceiling.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:54:56 +02:00
SenrokaiandClaude Opus 5.5 de34ffff43 Paint a system's derived surfaces after entering it, not while building it
Every body with no photograph gets a surface derived from its measurements, a 128 by 64 texture
painted on the main thread as its marker was built, inside the task that enters the system. At
about 4.4 ms each (measured in node for the twenty the next commit adds, 88 ms together), that is
the cost that grows with the number of bodies: with the solar system at 38 bodies, the long tasks
after selectStar(0) were [219, 72], [228, 79] and [177, 72] ms over three runs, against [85, 72],
[94, 75] and [78, 67] at 18.

buildMarker now gives such a body its kind's flat colour and hands the painting to the renderer,
which paints one surface per task (setTimeout 0) once the constructor has returned, and drops the
rest if the system is left first. Measured in the running app (port 4311, three runs each, long
tasks over 50 ms in the 9 s after entering the Sun's system):

- 18 bodies: [79], [73], [77] ms. The task that entered the system is under 50 ms.
- 38 bodies: [55, 72], [69, 78], [60, 83], and [52, 78] on a fourth run. The entering task is
  52-69 ms, down from 78-94 before this change with 18 bodies, so the twenty new bodies add no
  long task over what the branch had. What they still add to it is not measured apart.

The flat colour shows for a moment: the Sun's 29 derived surfaces were all painted 436, 689 and
399 ms after its renderer was built (three runs), and a surface once painted is cached, so a
return visit paints them at once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:52:32 +02:00
SenrokaiandClaude Opus 5.5 7b65ab4812 Draw a planet and a heavy moon going round their barycentre, as Pluto and Charon do
Standish's "Pluto" is the Pluto-Charon barycentre, and Charon is an eighth of Pluto's mass, so
that point lies 2 131 km from Pluto's centre, 943 km above its surface. Drawn the usual way, with
Pluto at its row's position and Charon going round it, Pluto sits where nothing is and Charon's
orbit is 2 131 km too wide on one side.

A moon record can now carry massRatio, its mass over its planet's. For such a moon the renderer
keeps the pivot at the planet's elements, which is the barycentre, and each tick puts the planet
massRatio / (1 + massRatio) of the relative separation back from it and the moon the rest out.
Both orbits are the relative ellipse scaled, the moon's by 1 / (1 + q) and the planet's by
-q / (1 + q), turned with the moon's node every tick: Charon's spans 17 460 km of radius and
Pluto's 2 131, round the same point, and neither passes through Pluto. Only Charon will carry it;
every other moon's barycentre is inside its planet.

Checked against Horizons in the unit suite, on JPL's records for the two: Pluto (999) from the
Pluto-system barycentre (9) in 2100 is 2 131.24 km out, and the renderer puts it within 5 km of
that length and 0.5 degrees of that direction, exactly opposite Charon at the inverse of their
mass ratio; Charon from Pluto is within 0.5 degrees of Horizons in 2100 (measured 0.37). The same
table adds Titania, against Uranus's equator 120 years from its 1980 epoch, within 0.75 (measured
0.62).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:49:47 +02:00
SenrokaiandClaude Opus 5.5 b0d7989c6f Read moons given against their planet's equator, and dwarf planets from the Small-Body Database
Two sources the missing bodies need, read and tested before any body uses them.

JPL's satellite table gives Uranus's and Pluto's moons against the planet's equator ("Mean
equatorial orbital elements") rather than a Laplace plane, and does not print that equator's
pole. parseSatelliteMeanElements now takes the pole from its caller for such a section and reads
the row against it exactly as against a Laplace plane's; it throws if a section is equatorial and
no pole was given, or a pole was given for a section that is not. Read as ecliptic elements, which
is what the old code would have done, Titania is 88 degrees from Horizons on 2025-01-01. The
section's plane is now the nearest heading above the row, with the ecliptic as before where there
is none.

Ceres, Eris, Haumea and Makemake are in none of Standish's tables. parseSmallBodyElements reads a
JPL SBDB answer (sbdb.api?sstr=...&phys-par=1&full-prec=1): the osculating heliocentric elements
against the J2000 ecliptic, carried round at their own n with nothing turning, and half the
published diameter and the rotation period where the answer has them. full-prec matters: without
it SBDB rounds to three figures, Ceres's n to 0.214 degrees a day for 0.2143045, 1.1 degrees out
within a decade. The fetcher, tools/etl/lib/mean-elements.ts, caches the answer like the others.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:47:55 +02:00
SenrokaiandClaude Opus 5.5 cdc0cf4678 Read the Horizons pages the missing moons are written in, and give triaxial bodies their mean radius
The pages of the moons this branch is about to add state their size and spin in forms the ETL
did not read. Charon's gives "Radius (km, IAU2015) = 606", which none of the radius patterns
matched, so it would have come out at radius 0. Phoebe's gives "Rotational period = 9h 16.438 m",
which the hours-or-days pattern read as 9 hours flat instead of 9.274. Pluto's and the moons' GMs
are now read too ("GM (planet) km^3/s^2 = 869.326" on Pluto's page, "GM (km^3/s^2) = 106.10" on
Charon's), for placing a pair's barycentre.

Miranda and Ariel, like Phobos and Deimos already, give three semi-axes, "240x234.2x232.9". The
first figure was taken as the radius, which is the longest axis. A triaxial body now gets the
radius of the sphere of its volume, the cube root of the product, which is how the IAU states a
mean radius. That changes two bodies already shipped: Phobos 13.1 -> 11.06 km (IAU 11.08) and
Deimos 7.8 -> 6.20 km (IAU 6.2). Nothing else in bodies.json moves.

The page parsers move from tools/etl/lib/horizons.ts to src/app/shared/astro/horizons-page.ts,
as the mean-element parsers did, so the unit suite runs their tests; the ETL imports them. A
small body's command ("1;" for Ceres) is now URL-encoded: sent raw, the semicolon made Horizons
refuse the request ("one or more query parameter was not recognized").

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 20:41:22 +02:00
SenrokaiandClaude Opus 5.5 2e5daa0f97 Give every body the period it is drawn going round in, and say where its orbit comes from
Audit #38: Europa's card listed its axis, eccentricity and inclination but no period, while the
scene turned it round Jupiter all the same: heliocentricPeriodDays refused every moon, since the
catalogue carried no planet masses.

Every solar-system body's period is now 360 over the JPL mean motion that carries it round the
scene, filed under Measured since that is JPL's published figure: Europa 3.55 d, the Moon 27.3 d,
Saturn 29.5 yr on the live cards, Earth 365.2564 d. heliocentricPeriodDays is gone. Exoplanets
keep the archive's period, or none.

The card's provenance line now ends with where the orbit comes from, "Orbit: JPL SSD satellite
mean elements, epoch 1997 Jan 16." for Europa, "Orbit: JPL approximate mean elements (Standish),
fit for 3000 BC to AD 3000." for a planet, and the Sun's system note says so too: "Orbits
propagated from JPL mean elements, the planets' fit for 3000 BC to AD 3000, to the current
date." Other systems keep "published elements". All three read in the running app.

Each has a test that fails without it (moons refused a period, provenance without the orbit, a
note without the source).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 20:20:51 +02:00
SenrokaiandClaude Opus 5.5 48319c3fe2 Move the solar system on JPL's mean elements, so it stays right as the clock runs
Every body carried one set of osculating elements from Horizons at 2025-01-01, run forward by
Kepler with a GM from a table of mass ratios. That set is exact at its instant and drifts from
then on, and the clock now runs a month a second: the Moon, with Earth's mass ratio lacking its
own and the osculating axis, went round in 27.70 days instead of 27.32, 66 degrees out after a
year, and its locked face was spun at the same wrong rate.

Planets and Pluto now take Standish's Table 2a/2b ("Keplerian Elements for Approximate
Positions of the Major Planets"): elements against the J2000 ecliptic, their rates per century,
and the b, c, s, f terms of Jupiter to Pluto, fit for 3000 BC to AD 3000. Table 1 is closer near
the present (Saturn 0.23 degrees at worst 1950-2100, against 0.32 here) but is only fit for
1800-2050, and by AD 3000 has Saturn 4.3 degrees out where Table 2 holds every planet within 0.3.
The moons take JPL SSD's satellite mean elements: sidereal mean motion n to ten figures, the
periods of their node and periapsis, and each one's local Laplace plane by its pole. They
propagate with n itself, never a GM: gmForParent and its mass table are gone. Horizons still
gives size, spin and obliquity.

Both tables are read from the Internet Archive's copy of JPL's pages, pinned to one capture: the
live approx_pos page has dropped Pluto, and the live sats/elem page has dropped n and rounds the
period to four or five figures (Phobos 0.3187 d, a revolution out within a decade).

What the tables leave implicit, measured against Horizons before it was accepted:
- The precession periods are magnitudes. A node regresses on a prograde orbit and advances on a
  retrograde one; a periapsis advances except where a resonance forces the eccentricity. Io's
  and Europa's follow their conjunction line backwards at 2 n(Europa) - n(Io) = 0.74 degrees a
  day, which is exactly the 1.625- and 1.394-year periods in the table. Read as advancing, Io
  was 0.9 degrees out and Europa 2.1.
- On a retrograde orbit the node's turning is added back to the mean anomaly. Taken off, Triton
  drifted a degree a year, 105 degrees by 2100.
- The Laplace frame's x axis is where the plane rises through the ICRF equator, RA of the pole
  plus 90. Read against the ecliptic, Io was 2.8 degrees out, Phobos 54 and Titan 127.

Orbit lines are now drawn in their own plane and turned by a quaternion each tick, so a turning
node carries the line with the body: fixed at one date, the Moon's line would be up to 69 000 km
off it nine years on. The Earth row is the Earth-Moon barycentre, 4 700 km from Earth, 0.002
degrees from the Sun. A tidally locked moon's day is now 360 / n, its sidereal period (the Moon
27.321662 d), so it stays locked to the orbit it is drawn on.

Angular error against Horizons VECTORS (ICRF, TDB; heliocentric for planets, planet-centred for
moons), degrees, read from the live renderer's markers in the running app:

body       1950-01-01 1975-01-01 1987-07-23 2000-01-01 2025-01-01 2037-03-06 2050-01-01 2075-01-01 2100-01-01   max
mercury         0.004      0.002      0.003      0.002      0.002      0.001      0.000      0.002      0.000  0.004
venus           0.003      0.007      0.003      0.004      0.004      0.004      0.003      0.004      0.004  0.007
earth           0.003      0.008      0.002      0.005      0.004      0.009      0.003      0.002      0.003  0.009
mars            0.009      0.010      0.008      0.024      0.009      0.012      0.009      0.011      0.028  0.028
jupiter         0.063      0.030      0.171      0.135      0.013      0.020      0.056      0.041      0.075  0.171
saturn          0.080      0.064      0.018      0.320      0.066      0.114      0.044      0.164      0.177  0.320
uranus          0.018      0.169      0.068      0.050      0.101      0.015      0.141      0.017      0.114  0.169
neptune         0.070      0.028      0.004      0.021      0.036      0.037      0.013      0.029      0.072  0.072
pluto           0.045      0.054      0.041      0.033      0.019      0.020      0.023      0.027      0.026  0.054
moon            0.486      1.928      0.127      0.631      1.407      1.086      0.720      0.339      1.180  1.928
phobos          2.068      0.294      0.881      1.113      0.313      0.636      2.089      5.862     11.099 11.099
deimos          0.077      0.043      0.310      0.066      0.164      0.068      0.034      0.468      0.044  0.468
io              0.021      0.015      0.010      0.019      0.009      0.035      0.006      0.011      0.022  0.035
europa          0.036      0.039      0.053      0.064      0.078      0.032      0.006      0.034      0.044  0.078
ganymede        0.132      0.103      0.018      0.007      0.023      0.054      0.091      0.118      0.044  0.132
callisto        0.040      0.019      0.023      0.019      0.038      0.008      0.060      0.119      0.056  0.119
titan           0.003      0.019      0.023      0.023      0.027      0.028      0.048      0.008      0.014  0.048
triton          0.051      0.029      0.009      0.021      0.052      0.048      0.063      0.089      0.137  0.137

Three miss what was hoped for, and why:
- Jupiter 0.17, Saturn 0.32, Uranus 0.17 against the 0.1 hoped for: short-period perturbations
  of the giants by one another, which no Keplerian fit carries. Standish states his own Table 2
  errors as 600, 1 000 and 2 000 arcseconds (0.17, 0.28, 0.56 degrees). Out to AD 3000, measured
  at 1800, 2200, 2400, 2600 and 3000, every planet stays within 0.3.
- The Moon, 1.9: evection (1.27) and variation (0.66), which a mean ellipse leaves out.
- Phobos, 2.1 until 2050, then 5.9 in 2075 and 11.1 in 2100, growing as the square of the time:
  its tidal acceleration, which the table has no column for. Its elements are MAR080's, epoch
  1950. The map's dates are also UTC where the elements are TDB, 69 s today,
  which is 0.9 degrees of Phobos and nothing for anything else.

Held in place by:
- build.ts: each body's mean elements against Horizons' own osculating elements on the ETL's
  2025-01-01, at most 0.25 degrees for a planet and 2.5 for a moon (measured: Uranus 0.101, the
  Moon 1.407; a regressing Triton node reads 10.24 and fails), and every moon's day equal to its
  sidereal period (a 1% error fails).
- Unit tests freezing nine Horizons vectors (Earth 2100, Jupiter 1950, Saturn 2075, Pluto 1975,
  the Moon 2050, Io and Europa 1950, Titan and Triton 2100) through SystemOrbitsRenderer, the
  Moon kept on its own turning line, the retrograde rule, the Standish terms, the Laplace frame,
  and both table parsers. Nine mutants each fail the test named for them, and the two
  validators each refuse a mutated build of the real catalogue.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 20:18:57 +02:00
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
Senrokai e45c3b6287 Merge pull request #32 from avalon-vanguard/fix/routes-panel-honesty
Let the Routes panel be clicked as soon as it is back, and stop it departing from elsewhere
2026-09-18 19:08:37 +02:00
SenrokaiandClaude Opus 5 2d06408c3f Merge main into fix/routes-panel-honesty
Both sides added a test beside the other in the dock's spec: the panel's own
departure guard here, the give-up wording on main. Both kept, and the offer test
carries the `least` the route answer now has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 19:04:08 +02:00
Senrokai 56870fd9fe Merge pull request #31 from avalon-vanguard/fix/grid-rings
Size the distance rings by what the frame reaches, and keep their labels off the star names
2026-09-18 19:01:15 +02:00
Senrokai cea39c7186 Merge pull request #30 from avalon-vanguard/fix/route-search-budget
Tell a search that gave up from a route that is not there
2026-09-18 19:00:03 +02:00
Senrokai 03b3d3b2d6 Merge pull request #29 from avalon-vanguard/fix/etl-gaia-floor
Refuse a Gaia answer that came back short, and read the body inside the retry
2026-09-18 18:59:28 +02:00
SenrokaiandClaude Opus 5 0c5efec505 Measure the ring span along the plane the rings lie in
The span went to `distanceRings` as the target's straight-line distance from the
Sun, but a ring of radius r passes within |r - p| of the view's centre, where p
is how far out that centre is *along* the galactic plane. For a target above the
plane the two differ by its height, so the band was centred on a radius no ring
has — and `ringLabels` picks its bearing by comparing its own in-plane distance
against the innermost ring, a comparison the new first ring quietly broke.

Two comments and a constant, from the same review. A frame short of the survey
edge gets its callout only when its last ring overshoots it: 245 pc does, 235 pc
does not, which is now a test rather than a sentence. The ring count can reach
16, not 14, now that the span need not start at the Sun. And a ring label was
measured as 135 px of star name when "50 pc" is a third of that, which rejected
rungs a hand's breadth clear of the name: RING_LABEL_REACH_NDC, 0.23, is the
widest of them — "1.5 kpc" with "Survey edge" under it.

Three mutants, three caught. Measured again in the app: unchanged for a star in
the plane (4 labels at 20 pc above it, 7 at 2 pc), and the rings now follow the
plane for one 195 pc above it rather than ringing a place the grid does not
reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 15:57:44 +02:00
SenrokaiandClaude Opus 5 337602f606 Make the budget test spend the budget, and bound the probes that earn nothing
The fixture built for "exactly MAX_VISITED stars reachable" was 338 short: its
random cloud leaves clumps the departure never reaches (the LCG gives 12 212
distinct positions for 39 999 stars), so the search settled 39 662 and the
pre-fix code answered `gaveUp: false` too. The test could not fail on the code
it was written to pin — and the mutant that seemed to prove otherwise was
failing to compile, not failing the test. It is now a line of 40 000 a parsec
apart with the island off the line: settled 40 000 exactly, 115 ms, and the
pre-fix code does report a give-up. Both mutants now compile and are caught.

The give-up cap also has to hold while the bisection has earned nothing: the
exception added for that case had no bound at all, so a search could spend the
resolution's own eight full-budget probes — about 17 s of "Plotting…" — where
two used to cost 4 s. Bounded at five. On the repo's crowded-knot fixture:
1.9 s for the unearned ceiling figure with the old cap, 7.2 s for a range the
bisection earned, and five probes is where that lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 15:51:38 +02:00
SenrokaiandClaude Opus 5 1a5785fb63 Keep the row cap live for the queries that can reach it
Gating it on `jobsQuery` switched it off for every override that *widens* the
query — which is the only way to fill `select top N` at all. `ETL_GAIA_MAGNITUDE_LIMIT=14`
asks for 500 000 rows, the sky holds more, and the answer is the limit rather
than the filters: exactly what the tripwire is for, and it no longer fired. It
now reads the row limit itself, so only a deliberately smaller slice is silent.

Measured with a synthetic answer of exactly 500 000 rows in the cache, under the
key the widened query hashes to: refused. With the `jobsQuery` gate back, the
same run keeps 500 000 Gaia stars and goes on to publish them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 15:46:42 +02:00
SenrokaiandClaude Opus 5 dd56eafba4 Answer the review: hold the offer to the same test as the button
`canPlot()` guarded the Plot button and not `raiseTo`, which is the other way
into `plot()`. So with a departure typed but never chosen, clicking "1.8 pc
would reach." moved the range control and plotted nothing: the panel then read
"No route at this range. 1.8 pc would reach." beside a control already set to
1.8. The offer carries the same `disabled` as the button, since it is the same
request by another route.

And the departure guard is trimmed, as the scene trims the same text before
offering matches for it: one space in the field left it looking empty, with no
suggestions to pick from, and Plot dead for no reason on screen.

Measured in the app, from inside Barnard's Star with Sirius as the destination:
offer enabled with the field empty, disabled once "Sol" is typed and never
chosen — a forced click then moves nothing — and enabled again when the field is
cleared, where it raises the range to 2.40 pc and plots 7 jumps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 15:05:42 +02:00
SenrokaiandClaude Opus 5 7fba48c808 Answer the review: size the rings to the band the frame covers, and clear the text
The rings are centred on the Sun and the frame need not be. Sizing their step
from how far the frame reaches — 210 pc for a star at 190 with the camera 20 pc
back — gives 20 pc rings at 180 and 200, both outside a frame 19 pc deep, so a
view away from the Sun still had no ring on it and no ladder of labels either.
`distanceRings` now takes the span the frame covers rather than its far edge,
and the step is a fifth of that: 5 pc rings from 165 to 210 for the same view.

Measured in the app, centred on a star 187 pc out in the galactic plane: 4 ring
labels drawn 20 pc above the plane and 7 from 2 pc, against 1 and none before.

The clearance was a radius around the anchor, and a label is a line of text
hanging 135 px to one side of its anchor: at 0.065 NDC apart, past the radius,
"50 pc" printed inside "Alpha Centauri". It is now tested against the span the
name occupies, on the side it hangs, with the radius kept for the pair whose
text runs the other way.

Also from the review: the ladder in the clearance test was built at exactly the
constant it tests, so 1057 of 2000 camera poses would have decided it by float
round-trip error — the rungs now sit 0.02 either side of the rule. And two
comments that were wrong: a frame one step short of the survey edge does get its
callout, and CSS2DRenderer hides a label behind the camera rather than drawing
it at the page edge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 15:01:05 +02:00
SenrokaiandClaude Opus 5 1a53f26474 Answer the review: say which search gave up, and let the bisection earn its offer
The panel printed "No route at this range." beside the range it was offering —
which is the sentence this branch exists to stop it printing. It was gated on
there being no offer, and a search that gives up usually has one: Sol to
HD 120147 at 4.5 pc spends the budget, offers 4.83 pc, and says there is no
route where a 71-jump route exists. The wording now follows the search at the
range that was asked for, and nothing else.

That needs the two give-ups kept apart, so `least` travels beside `gaveUp` to
the panel: one says the asked range was not searched out, the other that the
search for a range that would work was. HIP 69445 at 3 pc — asked-range search
exhaustive in 44 ms, ceiling probe out of budget — used to read "Too many stars
to search at this range." and now reads "No route at this range.", with nothing
claimed after it.

Two more from the same review. The budget flag was read off the settled count,
so a search that proved a dead end with the last star it was allowed reported a
give-up; it now records why the loop stopped. And the bisection's cap could fire
before a single probe had narrowed anything, leaving the ceiling route's own
longest hop as the answer: star 1000115173 at 3 pc was told to go to 8.00 pc,
the control's maximum, for a crossing that works at 6. It now offers 6.93.

Measured in the app, all three: "Too many stars to search at this range.
4.90 pc would reach.", "No route at this range." alone, and 7.00 pc in place of
8.00. The duplicated dead-end test now asks the question it was named for —
exactly the budget's worth of stars reaching each other and none of them the
destination — and each fix kills its own mutant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 14:46:47 +02:00
SenrokaiandClaude Opus 5 b7f277ea04 Answer the review: a short answer makes more survivors, and must not be skipped
Three things this got wrong. The direction: truncating Gaia leaves the HYG rows
whose counterpart it dropped without one, so survivors rise — 10 886 today,
12 711 at half the rows, 16 258 at a third — which the comment claimed was the
other way, and which decides whether the 15 000 ceiling can be leaned on at all
(it catches a truncation past about two thirds, and nothing shallower).

The throw: `fetchStars` catches everything a source throws and skips it, so a
truncated CSV was reported as "the archive was unreachable" one step after
`writeStarAssets` had already overwritten the published catalogue. Marked with
`GaiaAnswerError` and rethrown there, so an answer that cannot be worked with
fails the run where it happened. Measured end to end in a throwaway working
directory, 300 000 rows in the cache: fails, names the cache file to delete,
assets untouched. With the rethrow taken back out again: assets written, then
"the archive was unreachable".

The row limit: `rows.length >= ROW_LIMIT` is true for every reduced
ETL_GAIA_ROW_LIMIT, so the tripwire fired on exactly the deliberate slice the
override exists for — and told the operator to raise it. Gated on the same flag
as its neighbour. `ETL_GAIA_ROW_LIMIT=20000` now runs through; without the gate
it dies on the limit it was given.

Also: the row floor names the one cache file it is about rather than a glob that
takes the Hipparcos cross-match with it, and says an edited query is a third
reason it can fire — DEFAULT_QUERY_ROWS now sits under the query it counts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 14:35:17 +02:00
SenrokaiandClaude Opus 5 7ab92e61a1 Give the budget tests room and cells to run in
Both make a search spend its whole 40 000-star budget, twice over in the bisection, and the
CI runner timed out at the default five seconds. The crowds are now indexed in cells sized for the
ranges asked of them, as the real catalogue is, and the two tests carry their own 30 s timeout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 13:50:47 +02:00
SenrokaiandClaude Opus 5 365252f534 Let the Routes panel be clicked as soon as it is back, and stop it departing from elsewhere
From the review of #24. Two defects, both in a real browser.

The panel keeps its entries across a trip to another tab, but still replayed the acquire wipe on
the way back, and for the 380 ms that runs, its clip path swallows clicks: type "Siri", leave for
Readout, come back and click the Sirius suggestion, and the click lands on the star field behind
it — measured, the element under the pointer is the canvas, and the field stays "Siri". The wipe is
gone from this one panel: it is not acquiring anything it did not already have.

The departure field fell back to the star the view is in whenever nothing had been chosen, text in
the field or not. So a field reading "Sol" that was never resolved plotted from Barnard's Star:
"1 Barnard's Star, 2 Sol, 3 Sirius", the panel naming one departure and the route leaving from
another. Text nobody chose is no longer a departure, and the button waits until it is one or the
field is empty again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 13:42:11 +02:00
SenrokaiandClaude Opus 5 5dec528cee Size the distance rings by what the frame reaches, and keep their labels off the star names
From the review of #19. The rings are distances from the Sun, but their step was taken from
`effectiveDistance`, which under the plan view means the extent of the frame rather than how far
the camera is from the Sun. Centred on a star 200 pc out and flipped to 2D, the grid became rings
of 2 to 20 pc: not one of them on screen. The step now comes from where the view is centred plus
how far the camera is orbiting it, which is the same distance under either projection.

The set was also rebuilt while the grid was hidden, and every rebuild disposes the rings and
builds every vertex again; it now happens only while the grid is drawn.

The ring labels went straight to the overlay: never culled to the frame, and free to land on a
star's name. They now have to be on screen and clear of the names already placed, by half the
separation two names keep — they are a ladder up one ray a twentieth of the screen apart, and
holding them apart from each other would take "Survey edge" off the map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 13:24:16 +02:00
SenrokaiandClaude Opus 5 7971ec4007 Simplify: without a give-up the bisection can only have closed on the resolution
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 12:56:51 +02:00
SenrokaiandClaude Opus 5 862fb65ea4 Tell a search that gave up from a route that is not there
The route search stops after MAX_VISITED stars and returned null, which everything downstream read
as "the catalogue holds no chain". On the real catalogue that was wrong for real questions: Sol to
HD 120147 (136 pc) at 5 pc is 50 jumps, and the panel said there was no route. The budget also sat
under what the shipped catalogue needs, so it is now 40 000 rather than 20 000: both that route and
a star at 170 pc are found, and Sol to HD 2626 at 6 pc, which used to be refused after 4.7 s, plots
56 jumps in about 2 s.

A search now reports whether it gave up. The range search no longer counts a give-up as proof that
nothing routes below it — that is what reported ranges up to 29% too wide — and it stops after two
of them, since those are the probes that cost the most and settle the least: for HD 2626 at 3 pc it
offers 5.92 pc in about 4 s, against 6.13 pc in 4.7 s. At the panel's widest range the refused route
and the range search are the same question, so it is asked once. Where nothing can be said, the
panel says "Too many stars to search at this range." rather than claiming there is no route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 12:49:18 +02:00
SenrokaiandClaude Opus 5 44f6a8d086 Refuse a Gaia answer that came back short, and read the body inside the retry
The merge gate asks whether Gaia contributed any stars, never how many. The TAP service truncates
on its own timeout and still serves a well-formed CSV with a 200, ordered by magnitude — so a half
answer is the bright half, which is the half HYG overlaps. Every gate passes: Gaia stars are
present, HYG survivors go down rather than up, unmerged twins can only fall. The weekly job would
publish a catalogue missing two hundred thousand stars and the runner would cache it for the weeks
after. `fetchGaiaStars` now refuses fewer than 95% of the 412 765 rows its query holds, as its
sibling query already did, and refuses an answer that fills the row limit.

`fetchText` retried the request but not the body: a connection reset part-way through the 57 MB
CSV rejected out of the loop, with no wait and no second attempt. The read now happens inside it.

Also corrected: the merge gate's account of the HYG survivors (two thirds of them are stars Gaia
measures but the main query never downloads, since Gaia puts them past the 250 pc cutoff), and the
refresh workflow's comment on what happens when the archive is unreachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 12:12:49 +02:00
SenrokaiandClaude Opus 5 b4017fcafc Merge pull request #28 from avalon-vanguard/perf/link-budget
Budget the jump-link layer in pixels of line, nearest the view's centre first

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 12:07:20 +02:00
SenrokaiandClaude Opus 5 539a0f0a3e Answer the review: budget in drawn pixels, re-ask when the budget moves, sort only the band it runs out in
- The budget counted CSS pixels; lines are drawn in device pixels, so a screen scaled to 150% or
  200% drew 1.5-2x the calibrated line. It now counts the canvas's drawn pixels.
- A graph was re-asked only when the drawn stars changed, so with a star budget covering the whole
  catalogue, or a resize, its budget and centre stayed wherever the layer was turned on. A view that
  chose its stars again now asks, and a graph is rebuilt when the stars, the range or the budget
  changed (the budget by more than half the margin, or its centre by more than 5 pc).
- From inside a system the budget was worked out in astronomical units about the system's origin.
  Graphs are now asked for in parsec space only; the flight back out asks.
- Comparing budgets let a request re-asked with a slightly different one supersede its twin, and the
  twin's rejection cleared the state of the request that replaced it. A rejection now clears it only
  for the latest request.
- The worker sorted every link to keep a few thousand, 2.2x an unbudgeted build. It now bands links
  by distance, keeps every band before the one the budget runs out in, and sorts only that one:
  142-168 ms on the real catalogue against 233-388 ms, 103 ms unbudgeted, returning early when all fit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 19:03:11 +02:00
SenrokaiandClaude Opus 5 bd5e9d4b0d Budget the jump-link layer in pixels of line, nearest the view's centre first
With the drawn set following the view, the layer at 8 pc cost the integrated Radeon 503 ms a frame
at 30 pc from the Sun. Measured, the cost follows the length of line on screen (about 10 ms per
million pixels near the Sun), not the number of links: 100 000 links were 12 ms at the opening
view, 25 000 were 61 ms at 30 pc. So the budget is a length: a million pixels, turned into parsecs
at the depth the view is centred on, spent on the links nearest that centre by their nearer end.
Orbiting with links at 8 pc on the iGPU: 12-18 ms p50 at every pose measured, no long tasks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 18:23:55 +02:00
SenrokaiandClaude Opus 5 45d5209433 Merge pull request #27 from avalon-vanguard/feat/drawn-set-in-view
Draw what the camera shows: the budget goes to the stars in view

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 18:06:12 +02:00
SenrokaiandClaude Opus 5 977a4d8a92 Merge pull request #26 from avalon-vanguard/perf/drawn-set-one-walk
Choose the drawn stars in one walk of the brightness order

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 18:06:00 +02:00
SenrokaiandClaude Opus 5 3026cbde5f Merge pull request #25 from avalon-vanguard/perf/link-drawn-stars
Link only the stars that are drawn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 18:05:48 +02:00
SenrokaiandClaude Opus 5 c65a02e9bc Merge pull request #24 from avalon-vanguard/fix/routes-keep-input
Keep the Routes panel's entries across a trip to another tab

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 18:05:36 +02:00
SenrokaiandClaude Opus 5 9631ddf0a4 Answer the review: keep up with flights frame by frame, respect portrait frames, and let links follow an orbit
- Flights: the drawn stars were checked once a label pass, and a flight outruns that. Leaving a
  system jumps the camera to face another way, then zooms out forty-fold in a second: 74-83% of
  the stars that belong on screen were missing on the first frames back in parsec space, 33-50%
  before each re-choice on the way out. While the rig animates, the check now runs every frame.
  Probe on real flights (Gl 806, Barnard's Star, out): 0.90-1.00 of a fresh choice drawn on
  screen in flight, 1.00 on the frame of the jump; frame p95 12.2 ms, no long tasks. Choosing for
  the whole sky during flights was tried first and measured worse (0.15-0.18).
- Portrait frames: the turn and pan limits use the narrower half-extent, not the height.
- Links: a new drawn set arms the rebuild timer only when none is pending, so a continuous orbit
  gets a graph at most every 250 ms instead of never; an unchanged set asks for nothing.
- The centre-move test lets the first pass happen before moving the centre.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 17:57:29 +02:00
Senrokai 9ad0815acd Merge branch 'perf/drawn-set-one-walk' into feat/drawn-set-in-view 2026-09-17 17:35:24 +02:00
Senrokai a769d70015 Merge branch 'perf/link-drawn-stars' into perf/drawn-set-one-walk 2026-09-17 17:35:21 +02:00
SenrokaiandClaude Opus 5 52c5d3b144 Answer the review: share a graph request asked again, by its range and its drawn list
Graph requests were never shared, on the grounds that the scene never asks for the same graph
twice. It does: turning the layer off and on while the worker is busy asks again for the graph
already waiting. The new request superseded the old one, and the old one's rejection handler,
which finds its request by range and drawn list, wiped the state of the new one: the layer stayed
on with no graph. An identical request now shares the outstanding promise, a graph being the same
when its range matches and its drawn list is the very same array.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 17:35:18 +02:00
SenrokaiandClaude Opus 5 6cd0666067 Draw what the camera shows: the budget goes to the stars in view
The drawn set was two spheres, around the view's centre and around the Sun, then the brightest
stars anywhere, so most of the budget sat behind or beside the camera: at 30 pc from the Sun
15.8% of the drawn stars were on screen, at 5 pc 9.1%, in a plan view zoomed to 10 pc 3.8%.

The same tiers are now taken only from the camera's frame, widened by a quarter
(VIEW_MARGIN), with the planet hosts in view drawn first after the pinned stars, so every ring
circles a star that can be clicked. The set is chosen again at the label cadence once the view
has turned, zoomed or moved half the margin, switched projection or been resized, and once for
the whole sky on the way out to the Galaxy. Drawn stars on screen: 74-76% at 30 pc, 73-75% at
5 pc, 66% in the zoomed plan view, 91.5% at the opening view.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 17:04:08 +02:00
SenrokaiandClaude Opus 5 7064e34d02 Choose the drawn stars in one walk of the brightness order
Same stars in the same order, for less: one walk of the brightness index, reading positions laid
out in that order, sorts the view's neighbourhood, the Sun's and the rest as it goes, instead of
gathering both neighbourhoods in catalogue order and sorting them. A refocus in the page drops
from 11.3 ms to 4.6 ms (median; worst 19.1 to 7.1). This comes before the drawn set follows the
camera's turns, which makes refocusing far more frequent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 16:22:50 +02:00
SenrokaiandClaude Opus 5 c6206a8311 Link only the stars that are drawn
The jump-link graph linked the whole catalogue: 3.7 million links at 8 pc, 7.4-7.8 s in the
worker and a 443-515 ms frame on the main thread when they landed, and most of them between stars
that were neither drawn nor clickable. A graph request now carries the star field's drawn stars,
and the worker links only those, over an index of its own with cells as wide as the range. The
scene asks again once a new drawn set has held still for 250 ms.

The renderer is handed the graph's bounding sphere instead of computing it: three.js walked every
vertex on the main thread in the first frame that drew a new graph, 48-55 ms at 8 pc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 16:16:38 +02:00
SenrokaiandClaude Opus 5 c77d3ed1d9 Keep the Routes panel's entries across a trip to another tab
The panel was unmounted with its tab, so leaving it reset departure, destination and range. The
range reset was also a lie: the slider came back at 3 pc while the scene kept drawing the graph at
the range last chosen. The panel now stays mounted and is hidden while another tab is open, which
still replays the acquire wipe when it is shown again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 15:21:41 +02:00
SenrokaiandClaude Opus 5 96348161cc Merge pull request #23 from avalon-vanguard/star-map/perf/routing-worker
Plot routes and build the jump-link graph in a Web Worker

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 15:05:51 +02:00
SenrokaiandClaude Opus 5 962cb3f6bc Merge pull request #22 from avalon-vanguard/star-map/feat/drawn-set-follows-view
Draw the stars around wherever the view is, not only around the Sun

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 15:05:38 +02:00
SenrokaiandClaude Opus 5 e986f6bebf Merge pull request #21 from avalon-vanguard/star-map/perf/label-scan
Name the brightest stars by walking one order, instead of sorting 60 000 five times a second

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 15:05:27 +02:00
SenrokaiandClaude Opus 5 7efcd2e93a Merge pull request #20 from avalon-vanguard/star-map/feat/fast-routes
Route with A* over numeric cell keys, so a route can reach past the Sun's crowd

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 15:05:15 +02:00
SenrokaiandClaude Opus 5 e1c806d05b Merge pull request #19 from avalon-vanguard/star-map/feat/scale-tools
Give the map a scale bar, and rings that say how far from the Sun

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 15:05:01 +02:00
SenrokaiandClaude Opus 5 58656a5e34 Merge pull request #18 from avalon-vanguard/star-map/fix/hyg-gaia-distances
Draw each HYG star at Gaia's distance, and keep the ones Hipparcos misplaced

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 15:04:49 +02:00
SenrokaiandClaude Opus 5 f7a482b06a Merge pull request #17 from avalon-vanguard/star-map/guard/merge-quality
Fail the ETL on a merge that keeps the same star twice

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 15:04:37 +02:00
SenrokaiandClaude Opus 5 011ebe1356 Merge pull request #16 from avalon-vanguard/star-map/fix/host-sky-match
Match exoplanet hosts on the sky, at both epochs the archive might mean

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 15:04:25 +02:00
SenrokaiandClaude Opus 5 fd5a24ce72 Merge pull request #15 from avalon-vanguard/star-map/fix/merge-epochs
Bring Gaia to HYG's epoch before merging, and keep a star's name when it matches

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-17 15:04:12 +02:00
SenrokaiandClaude Opus 5 f156e03822 Answer the review: send the worker one request at a time, keep only the latest, and never wait for a dead one
The adversarial review confirmed three defects in this PR, all reproduced in
the browser.

1. Superseded graphs queued up in front of routes. The worker answers
   messages one at a time and cannot drop one it has started. With the
   jump-link layer on, every pause on the range slider posted a full graph
   build, seconds of work at 6-8 pc. Answers no longer wanted were thrown
   away only once built. A route asked for afterwards waited behind every
   one of them: a one-jump route took 44 s.

   RoutingClient now holds requests and sends them one at a time. While one
   is out, only the latest of each kind waits: a newer graph replaces an
   older one before it is ever built, and the older promise is rejected with
   SupersededRequest. Routes go ahead of graphs. The same question asked
   again while outstanding shares the answer rather than being worked twice,
   as when the layer is turned off and on during a build.

   The same scenario in the browser (layer on, range stepped 5 -> 8 pc with
   400 ms pauses, then Sol to Proxima): the route came back in 110 ms. The
   worker was sent "links 3, links 5, route, links 8"; 6 and 7 were never
   built.

2. A worker that failed left the panel stuck. With no error handling, a
   worker that failed to load (a 404 on its chunk after a redeploy) or
   threw left "Plotting…" and a disabled button for good, and a graph at a
   range could not be asked for again.

   The worker now answers an exception with a 'failed' message, which
   rejects that request. A worker that fails to load or dies is abandoned,
   and what it left outstanding, and everything asked afterwards, is
   answered in place. The scene releases the panel when a route fails, and
   forgets a graph range that was never drawn so it can be asked for again.

3. Nothing type-checked the worker. The application builder never reads
   webWorkerTsConfig, and bundles the worker with esbuild, which strips
   types without checking them. tsconfig.app.json leaves the file out. A
   type error in the worker shipped.

   `npm run worker:typecheck` (tsc -p tsconfig.worker.json) now runs in CI
   beside the other project checks. webWorkerTsConfig is removed from
   angular.json, since it only suggested that something checked the worker.

Tests with a fake worker cover one request at a time, a waiting graph
replaced and a route sent ahead of it, a question shared, a failure rejected
and the next request sent, and a failed worker's requests answered in place.
A scene test covers the panel released after a failed route. Negative
controls, each caught: several requests sent at once, a waiting graph kept,
graphs ahead of routes, a question asked twice, a failure answered as a
success, a failed worker waited on, the panel left pending, and a type error
in the worker (caught by worker:typecheck).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-16 15:27:50 +02:00
SenrokaiandClaude Opus 5 965739e99e Merge branch 'feat/drawn-set-follows-view' into perf/routing-worker
The label and star-field review fixes arrive under the routing client: the
scene keeps constructing RoutingClient beside the neighbourhood, and builds
the brightness index where it built the order. Both sides' new scene tests
are kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-16 15:14:13 +02:00
SenrokaiandClaude Opus 5 86e143131e Answer the review: pin by the index the neighbourhood holds, and choose again only when it can matter
The adversarial review confirmed three costs this PR added, all reproduced in
the browser.

- The first pinned refocus stalled the first flight of a session. The
  renderer built its own id-to-index Map of 423 651 entries the first time a
  star was pinned, which is at the first selection, inside the approach
  flight. The worst frame was 47-103 ms, and the Map stayed as a second copy
  of a lookup the scene already had. The scene now pins by catalogue index,
  through the StarNeighbourhood it builds at load (new `indexOf`), and the
  renderer takes indices. First selection, measured in the browser: worst
  frame 18 ms.

- At galactic scale every label pass rewrote the drawn set. The view centre
  sweeps hundreds of parsecs a pass there, far past any star, so each pass
  chose the same 70 000 stars again and uploaded 2 MB to the GPU: 11 times
  on the flight out to the Galaxy. The scene no longer refocuses at galactic
  scale, where the whole catalogue is a few pixels, and the renderer leaves
  its buffers alone when the drawn set is unchanged. Flight to the Galaxy:
  2 refocuses, no frame over 50 ms.

- At load the same set was chosen twice: once by the renderer's constructor
  around the Sun, and again by the first label pass, centred on the Sun. The
  scene now records the constructor's choice as the current focus.

Tests: the buffers keep their version for an unchanged set, no refocus at
load, none at galactic scale, and pins arrive as indices. Proxima's id in the
scene spec now differs from its index, so a lookup by id cannot pass for one
by index. Negative controls, each caught: an unchanged set rewritten anyway, a
refocus at galactic scale, the boot choice not recorded, and pins passed as
ids.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-16 15:12:46 +02:00
SenrokaiandClaude Opus 5 c3fcb2e481 Merge branch 'perf/label-scan' into feat/drawn-set-follows-view
The label fix turns the brightness order into an index with positions and ids
laid out beside it. The star field only needs the order, so it is handed
`.order`. Both sides added scene tests in the same place; both are kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-16 15:11:29 +02:00
SenrokaiandClaude Opus 5 b071d87d8a Answer the review: walk the brightness order in memory order, and stop at the fifteenth label
The adversarial review confirmed a regression in this PR. Near the Sun, the
label pass became three to four times slower than the scan and sort it
replaced.

Within about 11 pc of the Sun, and in any plan view zoomed tighter than that,
the label radius clamps to 4 pc. That sphere holds a few dozen faint dwarfs
deep in the brightness order, so the walk rarely finds fifteen stars to name
and reads nearly the whole catalogue. Reading the star objects in brightness
order jumps all over memory, so a full walk took 19-25 ms against the old
5-6 ms.

The review also found that spreadLabels checked the label count at the top of
its loop. After placing the fifteenth label it asked for a sixteenth
candidate, which near the Sun can lie at the far end of the order.

brightnessIndex now lays each star's position and id out beside the
brightness order, in that order. The walk tests stars from those arrays in
sequence and reads a star object only when it yields one. spreadLabels breaks
straight after placing the fifteenth label.

Measured on the real catalogue with the label logic reduced to what decides
placement, camera at the given distance from the Sun (old sort / this PR as
first pushed / now):
  2 pc    4.9 / 24.7 / 2.5 ms
  5 pc    5.7 / 23.0 / 3.1 ms
  10 pc   6.3 / 18.2 / 0.95 ms
  307 pc  22  / 0.01 / 0.00 ms (the opening view)
The labels are identical in every case. Now faster than the old sort at every
distance.

A new scene test counts the candidates spreadLabels takes: exactly fifteen for
fifteen labels. Negative controls, each caught: positions one axis off, ids in
catalogue order, the selected star dropped, the radius edge excluded, and the
count checked before taking a candidate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-16 15:04:51 +02:00
SenrokaiandClaude Opus 5 7f8fb59f5d Merge main: another scheduled refresh ran with the pre-fix pipeline, keep this branch's data
The 2026-09-14 refresh (7f187e0) regenerated exoplanets.json on main with the
host matching this stack replaces, so the file conflicted again. Resolved by
keeping this branch's, for the same reason as last time: it is the output of
the reviewed pipeline, and what main's side adds is only archive rows
published since, which the next refresh re-fetches with the fixed code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-16 14:23:11 +02:00
SenrokaiandClaude Opus 5 8c69a7a8b2 Plot routes and build the jump-link graph in a Web Worker
Route plotting ran on the main thread, and so did the jump-link graph:

- the range search for a far target, HD 2626 at 236 pc, takes 4-5 s;
- the graph at 8 pc is 3.7 million links, 6-10 s to build, then as many
  link objects again to turn into vertices.

The map stopped for as long as either ran.

A Web Worker now does both. RoutingClient sends it the catalogue's ids and
positions once, and it keeps its own spatial index. A route question comes
back with the route, or with the range that would open one. A graph comes
back as one Float32Array of segment vertices, transferred rather than
copied.

On the scene side, only the latest route request is shown: an earlier
answer arriving later is dropped. Only the graph for the range last asked
for is drawn. The Routes panel says "Plotting…" and holds its button while
a request is out.

collectJumpLinks gave way to jumpLinkSegments, which writes the vertex pairs
straight into floats rather than building link objects first; the scene was
its only caller. The routing module (routing.ts) is the message protocol and
the one function answering it, so the worker is a dozen lines, and the same
answers are worked out in place where there is no Worker, as in the unit
tests' DOM. The worker is built with its own tsconfig, as the Angular
builder expects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-16 14:17:26 +02:00
SenrokaiandClaude Opus 5 2d997e41db Draw the stars around wherever the view is, not only around the Sun
The star field draws a budget of the catalogue: everything within 25 pc of
the Sun, then the brightest of the rest. That choice was made once, at load,
around the Sun, and never again. On the Gaia catalogue it left most of the
map empty wherever the view went:

- a region 150 pc out drew 49 of the 442 stars within 25 pc of it;
- a plotted route ran through stars no one could see or click. Sol to
  Almach at 8 pc passes 19 stars and drew 6, Sol to Mirfak 11 of 26;
- a search for a faint star flew the camera to an empty point.

The drawn set now follows the view. The scene chooses it again at the label
cadence, once the orbit target has moved more than 5 pc or the pinned stars
have changed. The budget goes, in order, to the selected star and the stars
of a plotted route, then everything within 25 pc of where the view is
centred, then the same around the Sun, then the brightest of the rest. The
instance buffers hold the budget and are rewritten in place.

Checked in Chromium on WebGPU, framing Mirfak from 12 pc: with the set
chosen around the Sun, 122 of the 649 stars within 25 pc were drawn;
following the view, all 649. At the opening view the drawn set is the same
as before.

A refocus takes 9 ms in the browser (5 ms of it choosing). The first version took
16-36 ms in the browser, a visible hitch during a flight. Most of that time
went on walking the 423 651-star brightness order once per neighbourhood,
out of catalogue order, and on recomputing 70 000 colours. Now both
neighbourhoods are gathered in one pass in catalogue order and sorted on
their own, and colours and sizes are computed once for the whole catalogue.
The brightness order itself sorts a typed copy of the magnitudes, taking
83 ms at load instead of 104-139 ms.

STAR_RENDER_BUDGET is now 70 000, and its comment gives the measurements
behind it rather than "currently set to the whole catalogue", which stopped
being true when Gaia landed. At 1920 x 1080 on a Ryzen 7700X:

- on the RTX 4080, the whole catalogue costs the same 6.1 ms a frame as the
  budget;
- on the processor's two-core Radeon, standing in for an entry-level laptop,
  every 100 000 stars costs about 4 ms: 112 fps at the budget, 44 at the
  whole catalogue, and the same under WebGL2;
- drawn whole, the opening view turns into a grey wash that buries the
  labels and the host rings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-16 14:11:42 +02:00
SenrokaiandClaude Opus 5 0a0b301807 Name the brightest stars by walking one order, instead of sorting 60 000 five times a second
The star labels are refreshed every 0.2 s. Each pass filtered the whole
catalogue to the stars within the label radius, sorted them by magnitude,
and turned every one into a label object, all to place at most fifteen.
At the opening view the radius holds about 60 000 stars, so each pass was a
55-70 ms task on the main thread. A CPU profile of the opening view, on a
Ryzen 7700X with an RTX 4080, counted 29 tasks over 50 ms in 6.7 s, one
every 230 ms; updateLabels took 23% of the main thread. That is the stutter
the frame-time bench measured on every GPU and every render budget.

The catalogue is now sorted by brightness once, when it loads.
brightestWithin walks that order and hands stars over lazily, and
spreadLabels already stopped once it had placed fifteen labels, so a pass
reads only the stars it looks at. The output is the same as before: the
same stars, in the same order, with ties in catalogue order, the selected
star named wherever it is, and a star exactly on the radius included. The
spec checks it against the filter-then-sort it replaces. Stars are no longer
scanned at all when the view is at galactic scale, where the result was
thrown away.

Profiled again on the same view: 0 tasks over 50 ms, and the scene's
per-frame work over the window dropped from 2 028 ms to 342 ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-16 13:52:51 +02:00
github-actions[bot] 7f187e000e Refresh the astronomical catalogues
Scheduled re-run of the ETL against the live archives. Gated on the unit suite and a production build in this same run, because a GITHUB_TOKEN push triggers no CI of its own.
2026-09-14 10:54:47 +00:00
SenrokaiandClaude Opus 5 efe6667b00 Route with A* over numeric cell keys, so a route can reach past the Sun's crowd
The route search widened evenly from the departure, Dijkstra-style, with a
budget of 20 000 stars. On the Gaia catalogue those are all within about
40 pc of the Sun, so it found no route to anything farther at any range:
Sol to Mirfak (155 pc) failed at 3, 8, 15 and 30 pc alike. Every failure
then asked minimumRangeBetween what range would work. That search widened
the same way with a 30 pc ceiling, and it ran for up to a minute on the
main thread before giving up with nothing.

routeBetween is now an A* search. Each star is queued by the distance
travelled to it plus the straight line on to the destination, on a binary
heap rather than a linear scan of the frontier. It heads for the
destination instead of flooding the core around the departure.

minimumRangeBetween bisects the range, one routeBetween per step, because
whether a chain exists can only become truer as the range grows. Its
answer is always the longest hop of a route actually found, so a range it
names always opens one. Its ceiling is now the Routes panel's own
maximum, MAX_JUMP_RANGE_PC: a range the control cannot be set to is no
answer, and raiseTo already clamped any figure above it.

The spatial index keys its cells by one number packed from their three
indices instead of an "ix,iy,iz" string. A search visits up to 125 cells
for every star it expands, and building those strings was half of what a
route cost. forEachWithin hands neighbours over unsorted and uncollected,
which was most of the other half; within is now that, gathered and sorted.

The no-route line said nothing in the catalogue bridged the gap; it now
says no chain of jumps up to the panel's maximum reaches the star, which
is what was searched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-11 19:42:57 +02:00
SenrokaiandClaude Opus 5 43b9b1f081 Give the map a scale bar, and rings that say how far from the Sun
The map had one way to read a distance: the Range readout, a number for how
far back the camera is. The local grid's five rings sat at 50 to 250 pc,
fixed and unlabelled. They said nothing from inside a 2 pc hop, and nothing
past 250 pc now that the Hipparcos stars Gaia places there are drawn.

A scale bar now sits under the scale rail. It shows the longest round length
(1, 2 or 5 x 10^n) that fits in 120 px, in AU inside a system and in parsecs
or kiloparsecs outside. It is measured at the depth the view is centred on,
since under perspective every depth has its own scale; under the plan view
it is exact everywhere.

The local grid's rings are now distances from the Sun, at a round step of
about a fifth of the camera's distance and out past the camera: 50 to 350 pc
from the opening view, 2 to 20 pc from twenty parsecs out. Each ring is
labelled with its distance, on the side facing what the view is centred on,
or across the far side of the grid when that is the Sun (the near side is
under the camera and out of frame). The survey edge at 250 pc stays called
out, as "Survey edge", whatever the step.

The rounding lives in one place, scale-bar.ts, shared by the bar and the
rings and tested there. Its formatter keeps three significant digits: one
digit, enough for the bar's round lengths, printed the 250 pc ring as
"300 pc".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-11 19:26:39 +02:00
SenrokaiandClaude Opus 5 4eb61ff58e Draw each HYG star at Gaia's distance, and keep the ones Hipparcos misplaced
HYG and Gaia were both cut at 250 pc, each on its own distance. A star
Hipparcos put at 200 pc and Gaia at 300 was kept by the first, never
downloaded from the second, and drawn at 200. That is where 83% of the
9 691 mid-magnitude HYG stars without a Gaia counterpart came from, and at
the median Hipparcos had them a third too close. The mirror case, Hipparcos
outside and Gaia inside, dropped the HYG row and left its Gaia entry
anonymous.

Gaia's own Hipparcos cross-match (hipparcos2_best_neighbour, a fixed DR3
table of 99 525 rows) gives a usable Gaia distance for 97 751 of them.
placementDistancePc keeps a star either survey puts inside the cutoff, and
draws every kept star at the better measurement, inside the cutoff or not.
57 121 HYG stars now sit at Gaia's distance. 6 833 of them are past 250 pc:
Zet Per 230 -> 259 pc, 35 Ori 137 -> 330, 44 Cnc 223 -> 613, and the
farthest, HIP 69445, at 8.7 kpc. 3 666 stars that Hipparcos put outside are
now kept, and 3 656 of them give a Gaia entry its name.

The cross-match is required rather than skipped when unreachable. Without
it, every one of those stars would move back to its Hipparcos distance, and
the published map would flip with the archive's availability. The ESA TAP
answered it with a 500 at first and in 102 s on the next try. So fetches
now retry 5xx and network failures twice, after 30 s and 120 s, in the
fetch every source goes through. The refresh job also carries the Gaia DR3
responses from run to run in the Actions cache: the release is frozen, and
a live re-fetch has already reproduced stars.bin byte for byte.

423 651 stars (+10), 61 168 HYG rows folded into Gaia entries (+3 656),
351 597 unnamed designations (-3 656). 10 886 HYG survivors and 23 unmerged
pairs under an arcsecond, both inside the merge gate's ceilings. The same
1 972 exoplanets have a host; KELT-4 A b and MWC 758 c now sit on their
named star.

The HUD's "Radius" becomes "Survey radius": 250 pc is where Gaia is
surveyed to, and no longer the edge of the map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-11 19:07:12 +02:00
SenrokaiandClaude Opus 5 29d3ddb6ef Say so when Gaia is missing, rather than as 68 000 unmatched stars
With the merge gate in place, a Gaia DR3 outage no longer ships a HYG-only
catalogue: the ETL skips the unreachable source, and validateMerge then fails
on the survivor count. That is the right outcome and the wrong message: "68 000
HYG stars found no Gaia counterpart" sends the reader looking at the merge.
Gaia contributing nothing is now checked first, by name.

Two comments said Gaia was best-effort, in data-refresh.yml and on the merge
in fetchStars. They now say what happens instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-11 18:41:09 +02:00
SenrokaiandClaude Opus 5 f935bae3b3 Fail the ETL on a merge that keeps the same star twice
The catalogues are regenerated by a scheduled job that pushes straight to
main once the unit suite and a production build pass in the same run. Both
passed, every Monday, on a catalogue that carried 23 000 stars twice: the
suite tests code against fixtures, and no fixture is 400 000 real stars.
Nothing between the ETL and the map ever looked at what came out.

Two numbers now have to hold, and each is the signature of a way the merge
has actually failed here.

Different catalogues placing a star within an arcsecond of each other is
never two stars at this depth, and one catalogue does not list a star twice,
so every cross-source pair that close is a miss. Nineteen survive today —
each a second HYG row wanting a Gaia entry that already absorbed one, which
is how Gliese lists some doubles — against 1 112 in the catalogue on main,
where a Hipparcos parallax off by half outvoted a direction that agreed to
a hundredth of an arcsecond. The ceiling is 100.

The epoch failure leaves no close pair at all, because sixteen years of
proper motion had already carried the two entries tens of arcseconds apart.
What it leaves instead is HYG rows that found no counterpart: 36 056 on
main against the 10 876 Gaia genuinely lacks — the stars it saturates on and
the red dwarfs past its magnitude cut. The ceiling is 15 000.

The pair sweep sorts by declination and walks a one-arcsecond window, so it
costs about 300 ms on 423 641 stars — cheap enough to run on every ETL, which
is the point: the gate has to sit where the bot already is, before the push,
because a GITHUB_TOKEN push fires no CI of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-09 21:10:23 +02:00
Senrokai db511f7aa4 Merge branch 'fix/merge-epochs' into fix/host-sky-match 2026-09-09 20:49:40 +02:00
SenrokaiandClaude Opus 5 037545d036 Answer the review: a name two stars answer to names neither, and NaN is not a proper motion
Three guards the matcher was missing, none of which changes a byte of the
regenerated data — the ETL re-run after them is identical — and all three
now have a test that fails without them.

A proper motion that is not a number poisoned every comparison rather than
one: NaN loses every `<` it appears in, so `cosine < minCosine` was false
for every star, each one reached the distance guard, and the last one in
catalogue order won — a confident wrong answer, order-dependent, where the
honest answer is "no match". The archive's own parser never produces one
(parseOptionalNumber maps a blank cell to undefined), but the matcher is
exported for offline re-cross-referencing and a caller reaching for bare
Number() is exactly the coercion the CSV helper documents as having caused
two prior bugs. An unusable motion now reads as no motion.

Normalizing a name strips the dot, so `Gl 55.2` and `Gl 552` — two stars
135 degrees apart — share one key, and the index kept whichever came last;
64 such groups exist in the catalogue, among them `Gl 84.1A`/`Gl 841A` and
`HD 96600` twice. A name that names two stars names neither, so ambiguous
keys are dropped and the query goes to the sky, where direction settles it.
No archive hostname lands on one today, which is why the data is unchanged.

And the cache is keyed by the whole request rather than the query alone,
here and in gaia.ts: fetchTextCached records only that some response
arrived, so an endpoint edit would have kept serving the old host's bytes —
the same silent staleness the query hash was added to close.

The tests now discriminate what the comments claim. Eight mutants, each
caught: judging only the published position, only the carried-back one,
judging each star on its worse epoch rather than its better, letting a
distance-rejected star claim best-so-far and shadow the true host behind
it, an unguarded proper motion, a last-wins name index, a fixed angular
tolerance instead of a transverse one, and no distance guard at all. The
GJ 887 test grew a decoy standing halfway along the star's own track: it is
nearer than Lacaille 9352 at the published position and nearer at the worse
of the two epochs, so it wins unless both epochs are tried and the better
one decides — the property the test's comment had been claiming untested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-09 20:48:49 +02:00
SenrokaiandClaude Fable 5 3df5396349 Merge main: the scheduled refresh ran with the pre-fix pipeline, keep this branch's data
The 2026-09-07 "Refresh the astronomical catalogues" commit regenerated
exoplanets.json and stars-index.json on main with the merge this branch
fixes, so both sides touched both files. Resolved by keeping this branch's:
they are the output of the reviewed pipeline, and the one substantive thing
main's side carried — the HYG designation-prefix casing from 7a112e4 — is
code, not data, which this branch already regenerated with. What is
genuinely newer on main's side is eight planets the archive published after
this branch's fetch; the next scheduled refresh re-fetches the live archive
with the fixed pipeline and brings them back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-09 14:20:59 +02:00
SenrokaiandClaude Fable 5 080bbe16dc Match exoplanet hosts on the sky, at both epochs the archive might mean
The host cross-reference matched in 3D, nearest star within half a parsec.
That is the wrong space for the same reason the star merge learned it: a
direction is measured, a distance is inferred. At 170 pc half a parsec is a
ten-arcminute cone, wide enough to hand the planets of stars our catalogue
does not carry to whatever bright star floats nearest — HATS-6 b sat on
HD 39500, seventy arcseconds away. At 60 pc it is tighter than the routine
disagreement between the archive's Hipparcos distances and our Gaia ones,
which is how four bright giants (7 CMa, HD 81688, omi UMa, xi Aql) lost
their planets and GJ 15 A's landed on a neighbouring entry.

Hosts are now resolved like stars are merged: by name first, then the
nearest star on the sky within a transverse budget — angle times the
archive's distance, 0.01 pc — whose distance does not flatly contradict the
archive's (the merge's own 50 % ratio). The budget is transverse because the
dominant error is proper motion over an epoch difference, a physical
displacement that is the same in parsecs at every distance: as an angle it
is 60" for Proxima and 2" for a host at 100 pc. Measured on the 504 hosts
whose archive name matches a catalogue name outright, true pairs reach
3.4e-3 pc; shifting every host a quarter of a degree finds nothing else
within 0.01 but Proxima's own entry, whose budget at 1.3 pc is wider than
the shift.

The archive never says which epoch a position is for, and they are mixed:
alf Tau and GJ 273 publish J2000, HD 133131 and TOI-2459 publish Gaia's
J2016. So the query asks for sy_pmra/sy_pmdec too, tries each position at
both ends of those sixteen years, and judges a star on whichever is closer.
Guess one epoch and a fast star's planets land on a companion: J2016 puts
Aldebaran's on Gl 171.1B, J2000 puts GJ 15 A's on a Gaia entry 15.9" out.

1 972 of 6 354 planets now sit on a host, 1 548 before: 432 gained, 26 on a
better star (GJ 15 A to Groombridge 34, GJ 676 A off its companion,
HD 19994 to 94 Cet), 8 lost — six false 3D matches to stars the catalogue
never contained, and GJ 273 b/c, whose archive row says 5.92 pc for
Luyten's Star at 3.79: a distance in flat contradiction is exactly what the
ratio guard exists to refuse, and the number to fix is upstream.

The 2 pc "rematch" apparatus is gone. build.ts recomputed every match after
fetchExoplanets had already written the file — at a different tolerance, so
the log reported a match count the data did not contain — and the offline
entry point that persisted it had no caller. One matcher, one set of
constants, used once. The archive cache is now keyed by a hash of the TAP
query, so a response cached before the proper-motion columns cannot serve
rows without them, where a missing cell would quietly read as "does not
move"; the row's astrometry is stored with each planet, which is what made
these tolerances measurable offline in the first place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-09 14:14:05 +02:00
github-actions[bot] 9cef316359 Refresh the astronomical catalogues
Scheduled re-run of the ETL against the live archives. Gated on the unit suite and a production build in this same run, because a GITHUB_TOKEN push triggers no CI of its own.
2026-09-07 10:36:21 +00:00
SenrokaiandClaude Fable 5 dc2ce08694 Answer the review: direction settles distance, brightness is one-sided, and a lost id stops the scene
Three findings from the adversarial review of the merge, all reproduced.

The distance test was hiding 1 489 stars that sit under an arcsecond from
their Gaia entry with a Hipparcos parallax off by half — thirty of them at a
false few parsecs from the Sun (HIP 82724 at 3.7 pc, where Gaia has it at
62.8) — and the first audit did not see them because it counted residual
doubles through the same 50 % filter. Under three arcseconds the distances
are now not consulted: a coincidence of direction that close is never chance
at this depth (the quarter-degree shift finds none), and the parallax is the
thing to fix. Brightness keeps its say at any separation, and is now
one-sided: a folded entry may be five magnitudes fainter (a red dwarf in V
against G) but not one brighter, because an entry a magnitude brighter than
what is already at that spot is a primary Gaia does not carry — Almach,
Alfirk and Ashlesha had all been folded into their companions' entries,
93 in all. The sky grid wraps at 0h.

The Gaia query orders by source_id after G, so the row order — and the ids
assigned from it — is a function of the archive's content rather than of the
server's plan for 20 064 ties; the cache key is a hash of the query.

And a bookmark to a star id the catalogue no longer holds — 56 000 Gaia ids
change with this — sent the scene through reconcileSelection, enterSystem,
its decline, finishTransition and reconcileSelection again until the stack
overflowed. The selection is cleared instead, at the one place every path
goes through.

Regenerated: 423 641 stars, 57 512 HYG identities on Gaia positions, no HYG
id or name lost, no star within 20 pc left with an unclaimed Gaia entry under
an arcsecond. 403 HYG survivors still have an unclaimed Gaia entry within
60": 13 under an arcsecond, where the brightness guard does not trust HYG's
magnitude, and the rest components 3" to 60" from their counterpart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QL6F9Bgfh8SgAiAAcPB9Hw
2026-08-28 21:20:45 +02:00
SenrokaiandClaude Fable 5 08534279fb Bring Gaia to HYG's epoch before merging, and keep a star's name when it matches
Gaia DR3 gives positions for J2016.0, HYG for 2000.0, and the merge matched
them on the sky to one arcsecond without propagating any proper motion.
Sixteen years of motion is 62" for Proxima and 166" for Barnard's Star, so
every star faster than ~62 mas/yr — most of the nearest ones — was kept twice,
some 23 000 in all. The slow ones were matched, and lost: the merge kept
Gaia's row whole, so 102 proper names, 1 336 Bayer/Flamsteed names and
32 000 spectral types became "Gaia DR3 <id>" and "Unknown", and 92 named
exoplanet hosts handed their planets to their anonymous twin.

Gaia is now asked for its proper motions and carried back to J2000 before it
leaves the fetcher. HYG is placed from its own x/y/z columns, which are right
where its `ra` is not: that column was carried from the Hipparcos epoch
without the cos δ its motion needs, 17.9" off for Proxima. A match combines
the two entries — Gaia's position, HYG's name, type, magnitude, colour and id
— instead of choosing one. The tolerance is 15" with a five-magnitude guard,
both set by measurement: 55 457 pairs sit under 1" once the epochs agree, the
Gliese-only entries up to 12" (Ross 248), shifting every entry a quarter of
a degree finds 16 chance neighbours at 15", and the guard keeps Sirius out
of Sirius B's entry. Entries of one source are never merged with each other:
the 1 411 Gaia doubles resolved under 1" are two stars, not one.

Regenerated: 425 071 stars (was 447 410), 56 082 of them Gaia positions
carrying HYG identities; no HYG id or name lost; the sixteen stars nearest
the Sun carry no survey designation; 196 residual doubles, all components
17" or more from their counterpart. Five planets of four bright giants
(7 CMa, HD 81688, omi UMa, xi Aql) lose their host link: their Gaia distance
sits 0.7–1.1 pc from the archive's Hipparcos-based one, past the 0.5 pc the
host match allows. Matching hosts on the sky rather than in space, as the
merge does, is the follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QL6F9Bgfh8SgAiAAcPB9Hw
2026-08-28 20:22:49 +02:00
Senrokai 043d57f0e7 Merge pull request #14 from avalon-vanguard/feat/neighbours-named
Name the neighbours that have a name, before the ones that only have a number
2026-08-28 19:05:50 +02:00
SenrokaiandClaude Opus 5 7a112e4bb3 Answer the review: HYG's own last-resort name is a designation too
Junie: a HYG star that fell all the way through the ETL's naming chain --
no proper name, Bayer, Flamsteed, HD, Gliese or HIP -- is called "HYG <id>",
and with `source: 'hyg'` the predicate was looking for a lower-case "hyg "
prefix and calling it named. None in the current catalogue, but the path is
in `tools/etl/fetchStars.ts` and a refresh could walk it.

Fixed in the table rather than in the predicate: `hyg: 'HYG'` next to
`gaia: 'Gaia DR3'`, so the encoder, the decoder and the predicate all read the
one rule. The sourceless case reads the same entry instead of repeating it.

npm test 609/609, build and ETL typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fcUfL82nvyh9VebX1Fz6w
2026-08-27 20:16:11 +02:00
SenrokaiandClaude Opus 5 f14e252b19 Name the neighbours that have a name, before the ones that only have a number
The neighbour ring exists to say where you are. Since the catalogue refresh it
has been spending one of its four places in Sol on "Gaia DR3 5853498713190525696"
-- a nineteen-digit survey id for the star printed beside it as Proxima
Centauri, the same star twice -- and that duplicate row pushed Barnard's Star
off the ring altogether. 91.9% of the refreshed catalogue is named that way.

Named stars now come first, and survey designations fill in only where fewer
than four named ones are in reach. The line between the two is the one the
catalogue format already draws: a name is a designation when it is what the
star's source would generate for it. Judged by the prefix rather than by
rebuilding "prefix id" from the row, because the number after "Gaia DR3" is the
survey's own id, which the 32-bit row id cannot hold -- a round trip through
the id would have called every one of those stars named.

The preference lives on the index as `nearestPreferring`: the preferred pass
exhausts the search before the fill runs, so a named star is never outranked by
a nearer unnamed one. That is the whole point of asking.

The end-to-end spec names Barnard's Star again, on purpose. The four nearest
named stars to the Sun are a fact about space, not about which catalogue was
refreshed last, and without this change that is exactly the label that
vanished -- checked by running the spec with the preference stashed: it fails
on that line, and passes with it back.

npm test 609/609, npx playwright test 16/16 under CI=true --workers=2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fcUfL82nvyh9VebX1Fz6w
2026-08-27 20:06:04 +02:00
Senrokai 14f3fe8e66 Merge pull request #13 from avalon-vanguard/fix/catalogue-refresh-e2e
Follow the catalogue rather than a star it used to rank fourth
2026-08-27 19:50:09 +02:00
SenrokaiandClaude Opus 5 82d70ce871 Follow the catalogue rather than a star it used to rank fourth
The scheduled refresh on 24 August took the star catalogue from 68 388 rows to
447 410 and from 820 kB to 5.4 MB. No branch in the seven-PR chain had ever seen
it -- they were all cut before -- so nothing tested the combination until the
chain landed on main. Two things broke there.

The neighbour ring named Barnard's Star, and the spec clicked it by name. The
refresh is a Gaia DR3 merge that carries the same physical star twice: Proxima
Centauri at 1.296 pc and Gaia DR3 5853498713190525696 at 1.302 pc are one star,
as are Barnard's Star at 1.824 and Gaia DR3 4472832130942575872 at 1.828. The
extra row pushed Barnard's from fourth-nearest to fifth, off a ring that shows
four. The ring is doing exactly what it says; the spec was asserting the
catalogue's contents. It now reads whichever star the ring names and follows
that one, so the next refresh cannot demote it.

Four other assertions were timing out at Playwright's default five seconds --
not because anything was wrong, but because every one of these tests boots that
catalogue and the suite boots several at once on a software rasterizer. The
heavy waits already carried explicit longer timeouts; the default now matches.
Capping the worker count would have worked too, and would have cost every run
the time whether or not the machine needed it.

npm test 607/607, npx playwright test 16/16 at the default worker count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fcUfL82nvyh9VebX1Fz6w
2026-08-27 19:39:26 +02:00
Senrokai 42f304b30b Merge pull request #12 from avalon-vanguard/feat/hud-about
Say what is written about a world, when asked
2026-08-27 19:23:34 +02:00
Senrokai 4e2575674d Merge pull request #11 from avalon-vanguard/feat/hud-2d
Draw it flat: an orthographic plan view
2026-08-27 19:23:18 +02:00
Senrokai 3612d722c5 Merge pull request #10 from avalon-vanguard/feat/hud-bookmarks
Keep a place, and come back to it
2026-08-27 19:23:01 +02:00
Senrokai 6c5cc1ca19 Merge pull request #9 from avalon-vanguard/feat/hud-routes
Route between stars, through the crossings a chosen range allows
2026-08-27 19:22:44 +02:00
Senrokai 911d876f55 Merge pull request #7 from avalon-vanguard/feat/hud-neighbours
Name the neighbours, from inside the system
2026-08-27 19:22:28 +02:00
Senrokai 0c190539fe Merge pull request #6 from avalon-vanguard/feat/hud-scene
Point at what is selected: arcs, a leader to the card, rings on the systems
2026-08-27 19:22:12 +02:00
Senrokai c11087cb8f Merge pull request #8 from avalon-vanguard/chore/junie-align
Pin the reviewer, bound its run, and wake it when a pull request reopens
2026-08-27 19:19:32 +02:00
Senrokai ee02147a78 Merge pull request #5 from avalon-vanguard/feat/hud-dock
Dock the HUD: every tool and readout on one rail along the bottom
2026-08-27 19:19:19 +02:00
github-actions[bot] 46cb923849 Refresh the astronomical catalogues
Scheduled re-run of the ETL against the live archives. Gated on the unit suite and a production build in this same run, because a GITHUB_TOKEN push triggers no CI of its own.
2026-08-24 06:05:56 +00:00
Senrokai 59c2d8483e Merge branch 'feat/hud-2d' into feat/hud-about 2026-08-21 15:42:40 +02:00
SenrokaiandClaude Fable 5 e808f50faa Answer the review: the plan view clipped a system, and pulled its neighbours inward
Two more findings against this projection, both of the same shape as the last
two: something written to the camera that happens to be live, where the plan
view derives from the other one.

The system's own depth range — a near plane a five-hundredth of an
astronomical unit out, a far plane twenty thousand — was set on the active
camera. Entering a system with the plan view already on therefore wrote it to
a camera that re-derives near and far from the perspective one every frame, so
the range never applied and the system clipped. All three unit-space depth
writes go to the perspective camera now, which is the one they are reasoned in.

And the ring of neighbour names collapsed toward the middle of the frame. Its
placement unprojected a point on the ring, treated the offset from the camera
as a direction, and stepped a fixed distance along it — which is a perspective
construction. A parallel projection has no vanishing point to step towards:
every ray through the frame is the view direction, so normalising threw the
sideways part away. Measured before and after, from inside Sol: the two names
sat 319 and 335 pixels out under perspective, 104 and gone under the plan, and
323 and 335 with the unprojected point used as what it already is.

Verified: build clean, 596/596 unit, 16/16 end-to-end on the branch this merges
into, and the ring measured on both projections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-21 15:42:37 +02:00
SenrokaiandClaude Fable 5 7c16ad8627 Answer the review: a null byte in the source, and a language that may not be there
Two findings, and the first is the kind a person does not catch.

The article cache keyed on `${name}\0${qualifier}` — with the null byte written
into the file rather than escaped into the string. Git calls a file with one of
those binary and stops diffing it, and every editor between here and a reader
does something different with it. The separator was the right idea, because a
name can contain a space and `("Kepler-22 b", none)` and `("Kepler-22", "b")`
are different questions; it just has to be spelled `\0`.

And `navigator.language` is optional in the DOM's own typings and missing in
some embedded engines, where splitting it would have thrown on the first press
of About rather than falling back to English.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-21 15:41:38 +02:00
Senrokai 971aa66303 Merge branch 'feat/hud-2d' into feat/hud-about 2026-08-21 15:20:03 +02:00
SenrokaiandClaude Fable 5 8ceea02875 Wait for the boot as long as the boot takes
The specs added over this series each opened with an untimed check that the
canvas was there, which gives it Playwright's default five seconds. That is
the one wait in this suite that cannot be made faster: eight workers share a
software rasterizer, and what they are all waiting on is the same first paint.
Under a full parallel run five seconds is a coin toss, and it came up wrong on
the route-plotting spec.

Thirty, like every other wait in here that crosses a camera flight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-21 15:20:00 +02:00
Senrokai 005ba07750 Merge branch 'feat/hud-2d' into feat/hud-about 2026-08-21 15:13:28 +02:00
SenrokaiandClaude Fable 5 b05324337c Answer the review: the plan view was flat against the wrong plane
Two findings, both right, and the second was the feature not doing what its own
commit message said.

The depth range stopped being updated under a plan view. It is worked out in
perspective terms — near from the distance, far from eight times it — and the
orthographic camera derives its own range from that one, so skipping the
calculation left the far plane wherever it had been when the projection
changed. Flying out to the whole Galaxy from a plan view clipped away most of
it. The range is written to the perspective camera whichever one is live now,
and the plan view goes on deriving from it every frame.

And the galaxy-scale plan looked down the celestial pole. "The plane the
current scale is read against" is this system's orbital plane inside a system,
and the galactic plane outside one — but the fallback was the scene's own z,
which is the Earth's rotation axis. The normal is the north galactic pole now,
and up is the direction of the galactic centre, so a plan of the Galaxy is
laid out the way the model that draws it is described. The arms are face-on.

Verified: build clean, 596/596 unit, 16/16 end-to-end, and the Milky Way
photographed flat from 28.3 kpc with nothing clipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-21 15:13:24 +02:00
Senrokai 687bc3b01c Merge branch 'feat/hud-bookmarks' into feat/hud-2d 2026-08-21 15:08:43 +02:00
SenrokaiandClaude Fable 5 d397aaa7e0 Let the Solar System be kept
The Sun's catalogue id is 0, and the readout's keep control was shown by
`@if (keepableStarId(); as starId)` — which reads zero as "there is no star
here". Of the six hundred and thirty-four systems the map can be inside, the
one nobody could keep was Sol.

Checked against null now, with a test that keeps star zero, because this is the
sort of thing that comes back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-21 15:08:40 +02:00
Senrokai 18e9d85a1b Merge branch 'feat/hud-2d' into feat/hud-about 2026-08-21 14:56:03 +02:00
SenrokaiandClaude Fable 5 b2cb307b60 Merge the review fixes, and take Junie's on the hit radius with them
Carries the shared reference-viewport module and the cached card lookup up from
the branch they were reviewed on, and answers the one comment left against this
one.

The orthographic branch of the star field's hit test multiplied the angular size
by the frustum's half-height and then divided the result by that same
half-height. The two cancel: `setProjection` had already sized the sprite as
`angular * halfHeight / tan(REFERENCE_FOV/2)`, so dividing back out by the
half-height leaves the reference field of view and nothing else. Both
projections are one formula over a different angle now — which is also one
fewer division by a number that is zero if the frustum ever degenerates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-21 14:55:59 +02:00
Senrokai 50c0351870 Merge branch 'feat/hud-routes' into feat/hud-bookmarks 2026-08-21 14:52:35 +02:00
Senrokai a381c02cd0 Merge branch 'feat/hud-neighbours' into feat/hud-routes 2026-08-21 14:51:48 +02:00
Senrokai f42337c841 Merge branch 'feat/hud-scene' into feat/hud-neighbours
# Conflicts:
#	src/app/features/galaxy-system/galaxy-system-scene.component.ts
2026-08-21 14:51:12 +02:00
SenrokaiandClaude Fable 5 e853fe312e Answer the review: one reference viewport, one lookup for the card
Two of the three comments were worth taking.

The star field and the rings drawn over it each carried their own copy of the
reference viewport and field of view the angular sizes are figured against.
They agree today, and nothing would have told anyone when they stopped: a ring
would just sit a little wide of its star at some window sizes. One module now
holds the three constants and says what they are for.

The leader line to the object card looked the card's panel up by selector on
every frame it was drawn. The host element is stable and the panel inside it
only changes when a different body is selected, so the lookup is derived once
per change instead of sixty times a second.

Left alone: replacing `positions.set([x, y, z], i * 3)` with an index-by-index
loop to avoid a temporary array per host. It runs once, over six hundred and
thirty-four stars, at bootstrap, and the version with the temporary reads
better than the version without.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-21 14:50:19 +02:00
SenrokaiandClaude Fable 5 bd37bb8b96 Say what is written about a world, when asked
Every figure this map shows is a measurement or something derived from one,
and it says which. What it could not do was tell you what a place is — a
radius and an eccentricity do not say that Titan is the only moon with a dense
atmosphere. An About control on a body's panel now fetches the Wikipedia lead,
and it is labelled as what it is: prose from another site, credited and linked
back, below the line where this map's own figures end.

Only on the press. Nothing is fetched while a body loads, and nothing is
fetched twice.

Three things the encyclopedia does that had to be handled, all found by asking
it rather than by guessing:

It redirects, generously — "Proxima Cen b" lands on "Proxima Centauri b" and
"Kepler-22 b" on "Kepler-22b" — so the catalogue's own names can be sent as
they are, with no mapping table to maintain.

It disambiguates. "Titan" is a list of everything called Titan, and so are
"Mercury" and "Io". Wikipedia says so in the response, and this app happens to
know the kind, so a disambiguation is retried as "Titan (moon)". Only in
English: every wiki words its own qualifiers, and inventing a translation of
one would be inventing an article title.

And it rate-limits, which it did to me while I was checking the above. A
refusal to answer is not an empty answer, so the two are separate outcomes:
"Wikipedia has no article on this" is about the world, "Wikipedia could not be
reached" is about this minute — and only the first is remembered, so a second
press is allowed to try again.

The extract is capped and scrollable. A lead can run a dozen lines, and this
panel is anchored to the top of a viewport that may be shorter than the prose.

Verified: build clean, 606/606 unit including twelve for the lookup chain, 16/16
end-to-end including three that stub the encyclopedia — this suite tests the
panel, not Wikipedia — design detector clean, and the real thing exercised by
hand against Earth, Titan and Proxima Cen b at three viewport sizes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-21 14:40:51 +02:00
SenrokaiandClaude Fable 5 d9bd913458 Draw it flat: an orthographic plan view
A perspective camera leans everything away from the centre of the frame. In a
system that means the orbits are ellipses whose shape depends on where they
happen to sit on screen, so two planets on the same circular orbit do not look
like they are on the same circle. Plan view, in the Display panel, swaps the
projection for a parallel one and swings to look down the plane the current
scale is read against — the galactic plane out in the field, this system's own
orbital plane inside one. Circles are circles again, wherever they are.

Both halves are the feature and neither alone is it. The projection is what
makes the shape honest; the swing is what makes it worth looking at. Orbiting
still works afterwards, so a plan is where the view starts rather than a cage.

The engine now holds both cameras and keeps them in step, rather than making
one on demand: a camera that exists only while it is being looked through is a
camera whose pose is always one swap out of date. The orthographic frustum is
derived, never stored — it is the perspective camera's own frustum at the
current orbit distance, made parallel — which is why the camera flights work
through it untouched. They move the camera; the frame follows.

Three things had to be taught that a projection had changed.

Sprites. three.js turns an angular size into a world size only when it is
compiling against a perspective camera (SpriteNodeMaterial: `camera
.isPerspectiveCamera && sizeAttenuation === false`). Under a parallel one that
step is silently skipped and every star in the field collapses to a thousandth
of a parsec. The same arithmetic is now done in the node graph behind a
uniform, so one material serves both cameras without being recompiled — and
picking follows it exactly, since a star has to be clickable where it is drawn.

Depth. A parallel camera does not back away as its frame grows, so at galactic
framing the backdrop shell and half the Milky Way sit behind its own plane. Its
depth range is symmetric about it instead, which a linear depth buffer can
afford and a perspective one could not.

And distance. Half the map was keyed on how far back the camera was pulled —
the scale ladder, the crossfade, the label radius, the range readout — which
under a parallel projection says nothing at all, because the frustum sets the
extent. They all read one honest equivalent now: the distance a perspective
camera would need to frame the same thing.

Two defects found while verifying, both mine, both from this change:

The per-frame work was computed against the camera captured at bootstrap while
the renderer drew through the other one, so after a swap every label was
projected by a camera nobody was looking through.

And the zoom limits were derived from the orbit limits, which are in whichever
unit space the view is in. Reading them on the frame the scene swaps parsecs
for astronomical units pinned the zoom at the ratio between the two, and
leaving a system landed the view three kiloparsecs out. Zoom is a plain
multiplier on a frame the distance already sets, so it is bounded by a factor.

Verified: build clean, 595/595 unit including a new spec for the projection
arithmetic, 13/13 end-to-end including two that flatten a system and check the
ladder still knows how far out it is, design detector clean, screenshots of
both scales in both projections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 20:11:31 +02:00
SenrokaiandClaude Fable 5 307fd41be8 Keep a place, and come back to it
Restores the commit reverted off the routing branch, which is where it was
committed by mistake. The change is unmodified; only its branch is.

The map had no memory. Every visit started at the same overview, and a system
worth returning to had to be found again by name each time. A mark on the
readout and on a body's panel now keeps it, a Bookmarks tab lists what has been
kept, and choosing one goes there — a star by flying into its system, a body by
opening its page.

Local storage, not an account. This map asks nobody to sign in, and a list of
stars somebody liked is not worth a server. Every read of that store is
defensive, because it is a string a person can edit, another tab can write, and
a browser can refuse to hand over at all: a bad entry is skipped rather than
losing the rest, duplicates are collapsed since two entries for one place would
each toggle the other's control, the list is bounded so a hand-edited store
cannot decide how much this renders, and where storage is denied outright the
bookmarks still work for the visit — they just do not outlive it.

The name is stored alongside the id rather than looked up, so the list reads
before the catalogues have loaded, and a bookmark to something a later
catalogue no longer holds still says what it was instead of decaying into a
bare number.

The tab is offered even when it is empty, and says what the mark does: a tab
that appears only once you have already found the feature is a tab that never
taught anyone anything.

Choosing a kept place hands the panel back to the readout, the same move as
choosing a search result and for the same reason. That behaviour is what the
end-to-end spec caught missing — the readout it asserted on did not exist,
because the panel just used was still covering it.

Verified: build clean, 587/587 unit, 11/11 end-to-end, design detector clean,
screenshots at 1440x900 and 390x844.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 19:12:58 +02:00
Senrokai 9cdd8f9388 Revert "Keep a place, and come back to it"
This reverts commit bd3a5a4. The bookmarks work was committed onto this branch
by mistake — it belongs to its own pull request, and it has one, branched from
this branch's own tip. Reverting rather than rewinding because the branch is
published and a pull request is open against it: the diff this pull request
shows is what matters, and after this it shows the routing change alone.
2026-08-20 19:10:24 +02:00
SenrokaiandClaude Fable 5 bd3a5a48c9 Keep a place, and come back to it
The map had no memory. Every visit started at the same overview, and a system
worth returning to had to be found again by name each time. A mark on the
readout and on a body's panel now keeps it, a Bookmarks tab lists what has
been kept, and choosing one goes there — a star by flying into its system, a
body by opening its page.

Local storage, not an account. This map asks nobody to sign in, and a list of
stars somebody liked is not worth a server. Every read of that store is
defensive, because it is a string a person can edit, another tab can write,
and a browser can refuse to hand over at all: a bad entry is skipped rather
than losing the rest, duplicates are collapsed since two entries for one place
would each toggle the other's control, the list is bounded so a hand-edited
store cannot decide how much this renders, and where storage is denied
outright the bookmarks still work for the visit — they just do not outlive it.

The name is stored alongside the id rather than looked up. That way the list
reads before the catalogues have loaded, and a bookmark to something a later
catalogue no longer holds still says what it was instead of decaying into a
bare number.

The tab is offered even when it is empty, and says what the mark does. A tab
that appears only once you have already found the feature is a tab that never
taught anyone anything.

Choosing a kept place hands the panel back to the readout, which is the same
move as choosing a search result and for the same reason: the panel has done
its job and the thing to look at is now the scene. That behaviour is what the
end-to-end spec caught missing — the readout it asserted on did not exist,
because the panel that had just been used was still covering it.

Verified: build clean, 587/587 unit, 11/11 end-to-end including a spec that
keeps Earth, leaves the page, comes back to it from the list and forgets it,
and one that keeps Proxima Centauri, flies out to the field and flies back in
by what was kept. Design detector clean, screenshots at 1440x900 and 390x844.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 19:07:46 +02:00
SenrokaiandClaude Fable 5 68a919bd84 Route between stars, through the crossings a chosen range allows
The map could say where a star is and what is near it, and nothing about
getting from one to another. This adds the question and the answer: pick a
departure and a destination, choose how far a single crossing may be, and get
the chain — how many jumps, how far in total, and every star on the way, each
one a step you can fly to.

A jump link is not a feature of space. There are no corridors out there; a
link is a question asked of the catalogue, which is why the range is the
user's control rather than a constant. Two facts about that catalogue decide
what the answers look like, and both are stated in the code because they read
as defects otherwise. It is magnitude-limited, so it is dense around the Sun
and thins with distance — within 50 pc a 3 pc range links 99% of it into one
piece, while over the whole 250 pc reach the same range leaves most stars
alone. And a gap in it is a gap in what has been catalogued, not in what is
there.

That is why "no route" is not the end of the answer. Where no chain exists at
the range asked for, the panel says which range would open one — the chain
whose longest hop is as short as possible, found by the same search with the
cost of arriving somewhere being the worst hop taken rather than the sum — and
offers that number as a control to accept.

Departure defaults to wherever the view already is, so one field is usually
enough. Sol to Vega at 3 pc: four jumps, 10 pc, by way of Barnard's Star,
Struve 2398 B and HD 155876. Narrow it to 0.8 pc and it says 2.26 would reach.

The graph is drawn as one buffer of line segments and the route as a second,
brighter one over it, with the graph stepping back while a route is up: near
the Sun the links are a haze, and a thread through a bright cloud is not a
thread. Both fade out with the local layer, since from outside the Galaxy the
graph is a smear.

Two measurements shaped this. Asking the index for each star's neighbours in
turn — sixty-eight thousand sorted lists, thrown away — took eight seconds; the
grid now walks its own cells once and pairs them, which takes a quarter of one.
And the range control emits per pixel dragged, so the rebuild waits for the
hand to settle.

Three defects fixed on the way, all older than the routing:

hud-acquire animated with fill-mode `both`, which leaves its closing keyframe
applied for good — and that keyframe carries a clip-path. Every panel wearing
it has been clipping its own box ever since, so anything that had to escape
one was cut away and could not even be clicked. Nothing had needed to escape
until this panel's dropdown opened upward.

The routing fields returned nothing when typed into before the catalogue
finished loading, and stayed nothing until the next keystroke. The options are
derived from the query and the index together now, so they appear when the
second of the two arrives, whichever that is.

And a link was `3-7` walking one way and `7-3` walking the other, which is two
links to anything comparing them.

Verified: build clean, 571/571 unit, 9/9 end-to-end including two new specs —
one plotting Sol to Sirius, one narrowing the range until there is no route and
accepting the one it names — design detector clean, screenshots at 1440x900
and 390x844.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 18:39:22 +02:00
SenrokaiandClaude Fable 5 c1e7f6363d Pin the reviewer, bound its run, and wake it when a pull request reopens
Junie has been reviewing pull requests here since the key was added, and eight
of the last eight runs succeeded. What it was not, was configured like the
sibling repositories, and three of those differences are worth closing.

The action was referenced as `@v1`. It is the only third-party action in this
repository and the only one handed a repository secret, so its definition
should not be able to change under us. `v1` and `v1.7.5` resolve to the same
commit today — the pin is not about which code runs now, it is about who gets
to decide that later.

There was no timeout. A run that goes wrong hangs rather than stops, and the
pull request shows a pending check until Actions gives up on its own six hours
later. Forty-five minutes, not the thirty the siblings use: the longest review
this repository has actually had ran thirty-five, on the largest diff so far,
and a ceiling that cuts a successful review short is worse than none.

And a pull request closed and reopened had had no review since it was closed.

Left alone deliberately: the absent-key step, which says so in the run summary
instead of failing a pull request for a reason that has nothing to do with its
code, and the lack of a `branches:` filter — work here stacks feature onto
feature, and filtering on main would skip every pull request in a chain but the
last.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 17:59:15 +02:00
SenrokaiandClaude Fable 5 a6b30e84f0 Answer the review: a gradient over the names, a zero-distance neighbour, two lost docblocks
Three findings out of forty-one raised survived being argued against, and all
three were real.

The vignette was painting over the label layer. It lived in the HUD component,
which sits after the label host in the same stack with neither carrying a
z-index, so paint order was tree order and a decorative gradient was laid over
the names — worst at the edge of the frame, which is precisely where the
neighbour ring is. Composited, the ghost's distance line fell to 4.02:1, under
the floor the CSS next to it claims. The gradient is scene chrome rather than
HUD chrome, so it moves down between the canvas and the labels; the authored
contrast then holds as written, and every label near the edge — planets
included — is read against the sky rather than through a wash of void.

A binary companion printed "0.00 pc". A catalogue holds a close pair as two
rows at one position, so the nearest neighbour to one of them is the other,
zero away — the exact string the readout deliberately suppresses for a star's
distance from itself. The query now asks wide and drops any separation that
prints as no separation, compared through the formatter rather than against a
hand-picked epsilon so the rule survives the formatter changing.

And the edit that added all this had been spliced between updateSystemLabels'
docblock and its body, leaving a paragraph about labelling planets sitting
over the neighbour resolution and the method it described with nothing, plus
two divergent copies of the same fourteen lines seventy-five apart. Both are
back where they belong.

Verified: build clean, 558/558 unit, 7/7 end-to-end, design detector clean,
screenshots re-read in Sol and Proxima Centauri.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 17:40:10 +02:00
SenrokaiandClaude Fable 5 44c6a1f15e Name the neighbours, from inside the system
A system view could say everything about the star it was inside and nothing
about where that star was. The four nearest catalogue stars are now named
around the edge of it, each with its distance, each a button that flies there
— so a chain of neighbours can be walked without pulling back out to the field
between hops.

These are bearings, not sky positions, and that is the one deliberate
compromise here. A true direction was tried first and does not work: at this
field of view the visible cone is about 30 degrees, so on average one
neighbour in fifteen falls inside the frame — measured, not guessed, at one
label of four in Sol and none at all after a small orbit. What survives the
ring is the half of the direction a viewer can act on, which way to turn to
face it, and the ring reads as instrument rather than as scene because it sits
at a fixed radius. Real distance was never an option: Proxima is 268 000 AU
from Sol, thirteen far planes out, so the distance goes on the type line.

Proximity is answered by a new pure module rather than by a scan. A uniform
grid over the catalogue answers both "the k nearest to this star" and "every
star within n parsecs", the second being what the jump-link graph in the next
PR is built from — one scan per node, and the quadratic would show. Its spec
pins the grid against a brute-force sweep of a pseudo-random cloud, because a
spatial index is an optimisation and never a different answer.

Where the ring meets the HUD, the HUD wins: placement is given the boxes the
readout, the strip and the object card occupy, and slides a name along the
ring until it clears them, or drops it rather than print it half hidden. That
rule is a pure function with its own spec.

Four defects found while verifying this, three of them older than it:

The dock's flex column was pointer-events-auto and as wide as its strip, so
an invisible band above the strip swallowed every click in it — including,
but not only, a neighbour's. The column is transparent now and each surface
opts back in.

The ring was sized against the frame's height alone, which on a phone held
upright put it a viewport and a half wide: no neighbour was reachable on any
portrait screen. It is sized against the shorter side.

Picking a search result reopened the readout, which on a narrow viewport is a
sheet over most of the scene — reopening it onto whatever was just flown to.
Below sm it now folds away.

A selectable label's two lines are adjacent spans, so it announced as
"Sirius2.64 pc"; it carries an explicit label saying what it does.

Verified: build clean, 558/558 unit, 7/7 end-to-end including a new spec that
flies Sol to Barnard's Star by its label, design detector clean, screenshots
at 1440x900 and 390x844 in Sol and Proxima Centauri, and the keyboard path
walked: both names are in the tab order, focusable, with the accent ring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 17:06:25 +02:00
SenrokaiandClaude Fable 5 7591bcc0ea Point at what is selected: arcs, a leader to the card, rings on the systems
Four things the scene did not yet say, all about where to look.

Selection. Hovering or pinning a body raised its card in the corner, but
nothing in the scene said which point the card was about. Two hairline arcs
now bracket the body — the one mark borrowed from the ARK's control disc — and
a leader runs from their rim to the card's near edge, in screen space, once
per frame, because the body moves and the card's height depends on its
content. The selected body's own label swaps to the left of its point, since
the leader leaves the right and would otherwise cross the text.

Labels choose a side. Right by default; left when the text would run off the
right of the view, or into the reach of a label already placed to the right,
and never left when that would run off the left. The overlay hangs the label's
near edge on the point either way, so the hairline still meets the star.

Rings on the systems. A faint accent ring on every star known to host
planets — the one binary fact about a point of light worth reading at a
glance from the neighbourhood, since it is the one thing that says "there is
somewhere to go here". Drawn the way the star field draws stars, as
unattenuated instanced sprites with the ring a band of the quad's own uv, so
they sit on the field's points at any zoom; a first cut as three.js Points
rendered nothing at all under the WebGPU renderer. 634 of them are a lot at
the overview, so they are faint, small, fade with the local layer, and have
their own toggle — Systems — in the dock's Display tab.

Verified: build clean, 537/537 unit, 6/6 end-to-end, design detector clean,
screenshots at the overview, at 90 pc, and in Sol and Proxima with a body
hovered and pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-19 02:38:06 +02:00
SenrokaiandClaude Fable 5 8b507427d8 Answer the review: sky toggles in system view too, one media query, idempotent openSearch
Three findings from the automated review, all confirmed before acting.

The Sky toggle did nothing in system view. Its write lived only in the galaxy
crossfade, which the tick parks while the system group is up — and the sky is
still on screen there. It is now also written on toggle, in applyDisplay. The
suggested form of that fix crashed the scene on mount: the effect that calls
applyDisplay fires once at construction, before the engine has a scene, and
getScene() throws — the dock rendered no tabs at all. Guarded on
engine.isInitialized; verified with a screenshot of Proxima with the sky off.

isWideViewport built a fresh MediaQueryList on every document pointer-down;
one module-level query is read instead. And openSearch in the e2e support
clicked the Search tab unconditionally, which would fold it closed if a test
ever called it while already open; it now checks aria-selected first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-18 21:35:52 +02:00
SenrokaiandClaude Fable 5 4a1cc5240a Dock the HUD: every tool and readout on one rail along the bottom
The overlay had grown by accretion: a search box floating top-centre, a
readout panel bottom-left, a range readout bottom-right, and nothing that
said these were parts of one instrument. This puts them on one rail across
the bottom of the viewport — the dock — with a tab strip pinned to the bottom
edge and whichever panel is open growing upward from it. The top of the
screen keeps only the scale ladder and the nameplate, so the map itself is
what fills the frame.

Three tabs. SEARCH is the old search, with its field pinned to the bottom of
the panel and the results growing upward above it, so the thing being typed
into never moves while the list grows. READOUT is the old bottom-left panel.
DISPLAY is new: five layer toggles — labels, orbits, grid, deep sky, sky —
each a real scene object switched by visibility, except the ones the galaxy
crossfade already rewrites every frame, whose toggles fold into that
crossfade instead of fighting it. The range readout sits on the strip itself,
so it is readable whatever is open.

Behaviour worth stating: choosing a search result hands the panel straight
back to the readout, since the thing to look at is now the scene. `/` opens
the search from anywhere. Below `sm` the dock is the strip alone; a tap opens
a panel as a sheet, a tap on the scene folds it away. The body-detail page
gets the same dock with only the search — the info panel is its reading.

Two things found on the way. CSS2DRenderer gives every label its own
z-index for depth order, and the label host created no stacking context, so
labels painted over every HUD panel; `isolate` on the host keeps them under.
And starmap-hud's readout tests were really tests of the panel that moved,
so they moved with it.

Verified: build clean, 535/535 unit, 6/6 end-to-end, design detector clean,
screenshots at 1440×900 and 390×844 across galaxy, galactic, system,
body-detail, all three tabs and the layers-off state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-18 20:57:10 +02:00
139 changed files with 16223 additions and 1400 deletions
+6 -1
View File
@@ -110,12 +110,17 @@ node .claude/skills/run-star-map/driver.mjs probe inner
{ "name": "Earth", "kind": "Planet" }, { "name": "Earth", "kind": "Planet" },
{ "name": "Mercury", "kind": "Planet" }, { "name": "Mercury", "kind": "Planet" },
{ "name": "Venus", "kind": "Planet" } { "name": "Venus", "kind": "Planet" }
],
"neighbours": [
{ "name": "Proxima Centauri", "distance": "1.30 pc" },
{ "name": "Barnard's Star", "distance": "1.82 pc" }
] ]
} }
``` ```
`probe` accepts the same four view names. Labels are read from the CSS2D layer `probe` accepts the same four view names. Labels are read from the CSS2D layer
(`.map-label` > `.map-label-name` + `.map-label-kind`). (`.map-label` > `.map-label-name` + `.map-label-kind`). `neighbours` is the ring of nearby
stars named from inside a system (`.map-label--ghost`); it is absent where there are none.
## Run (human path) ## Run (human path)
+9 -3
View File
@@ -211,21 +211,27 @@ async function probe(page, view) {
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
// Labels are plain DOM in a CSS2D layer: .map-label > .map-label-name + .map-label-kind. // Labels are plain DOM in a CSS2D layer: .map-label > .map-label-name + .map-label-kind.
const labels = await page.locator('.map-label').evaluateAll((nodes) => // Neighbouring stars named from inside a system carry .map-label--ghost; they are reported
// apart from the system's own bodies, since they are not in the system being probed.
const all = await page.locator('.map-label').evaluateAll((nodes) =>
nodes nodes
.filter((n) => n.offsetParent !== null) .filter((n) => n.offsetParent !== null)
.map((n) => ({ .map((n) => ({
name: n.querySelector('.map-label-name')?.textContent?.trim() ?? '', name: n.querySelector('.map-label-name')?.textContent?.trim() ?? '',
kind: n.querySelector('.map-label-kind')?.textContent?.trim() ?? null kind: n.querySelector('.map-label-kind')?.textContent?.trim() ?? null,
ghost: n.classList.contains('map-label--ghost')
})) }))
); );
const labels = all.filter((label) => !label.ghost).map(({ name, kind }) => ({ name, kind }));
const neighbours = all.filter((label) => label.ghost).map(({ name, kind }) => ({ name, distance: kind }));
console.log( console.log(
JSON.stringify( JSON.stringify(
{ {
title: (await title(page).textContent())?.trim(), title: (await title(page).textContent())?.trim(),
level: (await level(page).textContent())?.trim(), level: (await level(page).textContent())?.trim(),
labelCount: labels.length, labelCount: labels.length,
labels labels,
...(neighbours.length ? { neighbours } : {})
}, },
null, null,
2 2
+7 -2
View File
@@ -37,14 +37,19 @@ jobs:
# different dependency tree than the one committed. # different dependency tree than the one committed.
- run: npm ci - run: npm ci
# Four TypeScript projects, checked by four different things. These two have no build of # Five TypeScript projects, checked by four different things. These three have no build of
# their own, so nothing else would ever compile them. # their own that checks them, so nothing else would ever compile them. The worker is bundled by
# the build, but esbuild only strips its types, and `tsconfig.app.json` leaves it out, since
# its lib is `webworker` rather than `dom`.
- name: Typecheck the ETL - name: Typecheck the ETL
run: npm run etl:typecheck run: npm run etl:typecheck
- name: Typecheck the end-to-end tests - name: Typecheck the end-to-end tests
run: npm run e2e:typecheck run: npm run e2e:typecheck
- name: Typecheck the routing worker
run: npm run worker:typecheck
# `tsconfig.spec.json` is compiled here, `tsconfig.app.json` by the build below. # `tsconfig.spec.json` is compiled here, `tsconfig.app.json` by the build below.
- name: Unit tests - name: Unit tests
run: npm test -- --no-watch run: npm test -- --no-watch
+15 -3
View File
@@ -39,9 +39,21 @@ jobs:
- run: npm ci - run: npm ci
# The ETL's cache directory is gitignored and this is a fresh runner, so every source is # Gaia DR3 is a frozen release: the same query returns the same bytes (a live re-fetch has
# fetched live (~50-100 MB). A failed fetch fails the run by design — no refresh is # reproduced stars.bin exactly), so its responses are carried from one run to the next
# better than a partial one — except Gaia, which the ETL itself treats as best-effort. # rather than re-downloaded every week from an archive that times out under load. The key
# follows gaia.ts, where the queries are written, so a changed query is fetched afresh.
- uses: actions/cache@v4
with:
path: tools/etl/.cache/gaia-dr3-*.csv
key: gaia-dr3-${{ hashFiles('tools/etl/sources/gaia.ts') }}
# Every other source is fetched live on this fresh runner. A failed fetch fails the run by
# design — no refresh is better than a partial one. That includes Gaia on a cold cache, by
# two different paths: its Hipparcos cross-match is required, so an unreachable archive
# fails the run from fetchStars itself, while its main query is skipped when unreachable and
# the merge gate in build.ts then refuses a catalogue it contributed nothing to. An archive
# that answers short rather than not at all is caught in fetchGaiaStars.
- name: Rebuild the datasets - name: Rebuild the datasets
run: npm run etl run: npm run etl
+17 -2
View File
@@ -5,7 +5,11 @@ name: Junie review
# it reads well, and a review comment should never be able to turn the build red. # it reads well, and a review comment should never be able to turn the build red.
on: on:
pull_request: pull_request:
types: [opened, synchronize, ready_for_review] # `reopened` because a pull request closed and reopened has had no review since it was
# closed, and `ready_for_review` because the draft guard below would otherwise skip a pull
# request opened as a draft forever. No `branches:` filter: work here stacks feature onto
# feature, so filtering on main would skip every pull request in a chain but the last.
types: [opened, synchronize, reopened, ready_for_review]
# A review of the previous push is stale the moment a new one lands, so supersede it rather than # A review of the previous push is stale the moment a new one lands, so supersede it rather than
# letting two reviews comment on the same pull request. Keyed by pull request rather than by ref # letting two reviews comment on the same pull request. Keyed by pull request rather than by ref
@@ -25,6 +29,11 @@ jobs:
review: review:
name: Review the diff name: Review the diff
runs-on: ubuntu-latest runs-on: ubuntu-latest
# A ceiling, not a target: a run that goes wrong hangs rather than stops, and the pull
# request shows a pending check until it does. Set above the longest review this repository
# has actually had — 35 minutes, on the largest diff so far — rather than at the sibling
# repositories' 30, which would have cut that one short.
timeout-minutes: 45
# Drafts are work in progress and forks cannot see `JUNIE_API_KEY` — GitHub withholds secrets # Drafts are work in progress and forks cannot see `JUNIE_API_KEY` — GitHub withholds secrets
# from `pull_request` runs on forked branches, so the job would fail on a missing key rather # from `pull_request` runs on forked branches, so the job would fail on a missing key rather
# than say anything useful about the code. # than say anything useful about the code.
@@ -54,7 +63,13 @@ jobs:
# clone is never used. # clone is never used.
fetch-depth: 1 fetch-depth: 1
- uses: JetBrains/junie-github-action@v1 # Pinned to a commit rather than to `v1`: this is the only third-party action here and it
# is handed a repository secret, so its definition should not be able to change under us.
# `v1` resolves to this same commit today; the pin is about who gets to move it. The pin
# covers this definition only — the composite pulls its own dependencies by tag and fetches
# the Junie CLI over the network. Bump by resolving the new release's commit, never by
# moving a tag name.
- uses: JetBrains/junie-github-action@3f6a906f11c6f67c76efaf3d3264bbb615f9ce29 # v1.7.5
if: env.HAS_JUNIE_KEY == 'true' if: env.HAS_JUNIE_KEY == 'true'
# An opinion, not a gate. If Junie is down or rate-limited that is worth seeing in the # An opinion, not a gate. If Junie is down or rate-limited that is worth seeing in the
# log, but it is not a reason to hold a pull request whose tests pass. # log, but it is not a reason to hold a pull request whose tests pass.
+22 -9
View File
@@ -53,9 +53,19 @@ in it is measured and what is not.
![The solar system: orbit ellipses over a dashed reference grid marking 5 AU rings out to 35 AU](docs/screenshots/system-view.jpg) ![The solar system: orbit ellipses over a dashed reference grid marking 5 AU rings out to 35 AU](docs/screenshots/system-view.jpg)
**System view** — selecting a star flies the camera continuously into its system rather than **System view** — selecting a star flies the camera continuously into its system rather than
cutting to a new scene. The Sun gets the real solar-system bodies from JPL Horizons; other cutting to a new scene. The Sun gets the real solar-system bodies, moving on JPL's mean orbital
elements — Standish's for the planets, JPL SSD's satellite table for the moons, the Small-Body
Database for Ceres, Eris, Haumea and Makemake — and turned by the IAU's rotational elements
(Eris, Haumea, Makemake and Nereid, which have none, at their measured days about their orbit
normals, and Hyperion, which tumbles, not at all), Earth by the IERS Earth Rotation Angle; a
tidally locked moon's prime meridian turns at its JPL mean motion, and its pole's terms that turn
within 5 per cent of a multiple of its node's rate at that multiple of its JPL node rate, both
re-phased to the IAU's values on 2025-01-01 (the Moon's and Phobos's are left as the IAU has them,
and so are the circles Ariel's, Umbriel's, Titania's and Oberon's poles go round on, at rates none
of their nodes has), and Iapetus's pole follows its orbit normal
(`lockedToOrbit`), so each keeps its face to its planet from AD 1 to 3000; other
stars get their confirmed exoplanets. Orbits are drawn as ellipses and bodies are propagated stars get their confirmed exoplanets. Orbits are drawn as ellipses and bodies are propagated
along them by a Kepler solver against the current epoch. Under them, a dashed grid marks out along them by a Kepler solver to the date on the map's clock. Under them, a dashed grid marks out
round distances in AU — 5 AU rings for the solar system, 0.01 AU rings for TRAPPIST-1 — with a round distances in AU — 5 AU rings for the solar system, 0.01 AU rings for TRAPPIST-1 — with a
drop line from each body, so eccentricity and inclination read against a circular reference drop line from each body, so eccentricity and inclination read against a circular reference
instead of having to be inferred from a shape in space. The camera frames that grid rather than instead of having to be inferred from a shape in space. The camera frames that grid rather than
@@ -109,7 +119,8 @@ its own readout, so a stale image is visible as one.
both backends. Their size is angular rather than world-space — real stars are unresolvable both backends. Their size is angular rather than world-space — real stars are unresolvable
point sources, so apparent size should follow brightness, not distance. point sources, so apparent size should follow brightness, not distance.
- **One reference frame, from three sources.** HYG gives star positions in equatorial J2000. - **One reference frame, from three sources.** HYG gives star positions in equatorial J2000.
JPL Horizons reports orbital elements against the ecliptic, tilted 23.4° away. The Exoplanet JPL gives the planets' orbital elements against the ecliptic, tilted 23.4° away, and the moons'
against the ecliptic (the Moon), a Laplace plane, or their planet's equator (Uranus's and Pluto's). The Exoplanet
Archive measures inclination from the *plane of the sky* — perpendicular to our line of sight Archive measures inclination from the *plane of the sky* — perpendicular to our line of sight
to each host star, which is why transiting planets cluster at 90°. Each set of elements is to each host star, which is why transiting planets cluster at 90°. Each set of elements is
rotated from its own reference plane into the scene's equatorial frame, so a direction means rotated from its own reference plane into the scene's equatorial frame, so a direction means
@@ -134,9 +145,11 @@ its own readout, so a stale image is visible as one.
### On surfaces that were never photographed ### On surfaces that were never photographed
Fifteen bodies here have a real photograph. Everything else does not, and never will on current Twenty-eight bodies here are wrapped in real photography: the Sun, the eight planets and the Moon,
instruments: no exoplanet's surface has ever been imaged, and a few of the solar system's own and eighteen moons and dwarf planets in mission mosaics, grey where no probe has seen them
moons have no usable map in this asset set either. (`src/assets/textures/README.md`). Everything else is not, and no exoplanet ever will be on
current instruments: none has had its surface imaged. The five large moons of Uranus and a few
small bodies have no map in this asset set either.
Those bodies get a surface reasoned from what *has* been measured, in a chain that is worth Those bodies get a surface reasoned from what *has* been measured, in a chain that is worth
following because every link is standard: following because every link is standard:
@@ -200,7 +213,7 @@ re-runs are cheap and offline-friendly; set `ETL_FORCE_REFRESH=1` to bypass the
| Script | Source | Output | | Script | Source | Output |
| --- | --- | --- | | --- | --- | --- |
| `fetchStars.ts` | HYG database, plus any other positional catalogue wired in (see below) | `stars.bin`, `stars-meta.bin`, `stars-index.json` | | `fetchStars.ts` | HYG database, plus any other positional catalogue wired in (see below) | `stars.bin`, `stars-meta.bin`, `stars-index.json` |
| `fetchSolarSystem.ts` | JPL Horizons / SSD | `bodies.json` | | `fetchSolarSystem.ts` | JPL SSD mean elements (Standish's planets, the satellite table), the Small-Body Database, NAIF's PCK, JPL Horizons | `bodies.json` |
| `fetchExoplanets.ts` | NASA Exoplanet Archive (TAP) | `exoplanets.json` | | `fetchExoplanets.ts` | NASA Exoplanet Archive (TAP) | `exoplanets.json` |
| `fetchDeepSky.ts` | OpenNGC | `deepsky.json` | | `fetchDeepSky.ts` | OpenNGC | `deepsky.json` |
@@ -324,7 +337,7 @@ plugin's own files are kept so it can be listed from a marketplace of its own la
## Data credits ## Data credits
Star catalogue: [HYG database](https://github.com/astronexus/HYG-Database) (Hipparcos, Yale Star catalogue: [HYG database](https://github.com/astronexus/HYG-Database) (Hipparcos, Yale
Bright Star, Gliese) — 68 388 stars within 250 pc. Solar-system ephemerides: NASA/JPL Horizons. Exoplanets: NASA Exoplanet Bright Star, Gliese) — 68 388 stars within 250 pc. Solar-system orbits: JPL approximate planetary mean elements (Standish), JPL SSD satellite mean elements and the JPL Small-Body Database; rotation: the IAU WGCCRE 2015 report via NAIF's pck00011, with a locked moon's W and its pole's terms within 5 per cent of its node's rate re-rated to its JPL mean elements (but the Moon's and Phobos's) and Iapetus's pole carried round its orbit normal, and for Earth the IERS Conventions 2010; physical data, and the positions the orbits are checked against: NASA/JPL Horizons. Exoplanets: NASA Exoplanet
Archive. Deep-sky objects: [OpenNGC](https://github.com/mattiaverga/OpenNGC). Body and skybox Archive. Deep-sky objects: [OpenNGC](https://github.com/mattiaverga/OpenNGC). Body and skybox
imagery: NASA/JPL/USGS public domain and Solar System Scope (CC BY 4.0) — per-file provenance imagery: NASA/JPL/USGS public domain and Solar System Scope (CC BY 4.0) — per-file provenance
is recorded in `src/app/shared/rendering/texture-catalog.ts`. is recorded in `src/assets/textures/README.md`.
+62
View File
@@ -0,0 +1,62 @@
import { expect, test } from '@playwright/test';
/** Every request the article service makes, whichever language it is asking in. */
const isWikipedia = (url: URL): boolean => url.hostname.endsWith('wikipedia.org');
/** The article Wikipedia would send, stubbed: this suite tests the panel, not the encyclopedia. */
const SUMMARY = {
type: 'standard',
titles: { normalized: 'Titan (moon)' },
extract: 'Titan is the largest moon of Saturn.',
content_urls: { desktop: { page: 'https://en.wikipedia.org/wiki/Titan_(moon)' } }
};
test.describe('About', () => {
test('fetches an article only when asked, and says where it came from', async ({ page }) => {
let requests = 0;
await page.route(isWikipedia, async (route) => {
requests++;
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SUMMARY) });
});
await page.goto('/body/titan');
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
// Nothing has been fetched yet: the panel is measurements until a reader asks for prose.
expect(requests).toBe(0);
await expect(page.getByText('Titan is the largest moon')).toHaveCount(0);
await page.getByRole('button', { name: 'About', exact: true }).click();
await expect(page.getByText('Titan is the largest moon of Saturn.')).toBeVisible();
const credit = page.getByRole('link', { name: /Wikipedia/ });
await expect(credit).toHaveAttribute('href', 'https://en.wikipedia.org/wiki/Titan_(moon)');
expect(requests).toBeGreaterThan(0);
});
test('says nothing is written rather than leaving the press unanswered', async ({ page }) => {
await page.route(isWikipedia, (route) => route.fulfill({ status: 404, body: '{}' }));
await page.goto('/body/titan');
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'About', exact: true }).click();
await expect(page.getByText(/Wikipedia has no article on Titan/)).toBeVisible();
});
test('tells being unable to ask apart from there being no answer, and offers to try again', async ({ page }) => {
await page.route(isWikipedia, (route) => route.abort('failed'));
await page.goto('/body/titan');
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'About', exact: true }).click();
await expect(page.getByText(/could not be reached/)).toBeVisible();
// And the retry actually retries, rather than reading back a remembered failure.
await page.unroute(isWikipedia);
await page.route(isWikipedia, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SUMMARY) }));
await page.getByRole('button', { name: 'Try again' }).click();
await expect(page.getByText('Titan is the largest moon of Saturn.')).toBeVisible();
});
});
+59
View File
@@ -0,0 +1,59 @@
import { expect, test } from '@playwright/test';
import { openSearch } from './support/open-search';
test.describe('Bookmarks', () => {
test('keeps a body, comes back to it in a later visit, and forgets it', async ({ page }) => {
test.setTimeout(90_000);
await page.goto('/body/earth');
await expect(page.getByRole('heading', { name: 'Earth' })).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'Keep Earth' }).click();
await expect(page.getByRole('button', { name: 'Forget Earth' })).toHaveAttribute('aria-pressed', 'true');
// A later visit, on a different page: kept places outlive the one they were kept from.
await page.goto('/');
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
// it waits on is the slowest thing in it. The default five seconds is a coin toss
// under a full parallel run.
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: 'Bookmarks' }).click();
const kept = page.locator('#dock-panel-bookmarks li').filter({ hasText: 'Earth' });
await expect(kept).toHaveCount(1);
await kept.getByRole('button').first().click();
// Choosing it goes there, which for a body is its page.
await expect(page).toHaveURL(/\/body\/earth$/, { timeout: 30_000 });
await expect(page.getByRole('heading', { name: 'Earth' })).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'Forget Earth' }).click();
await expect(page.getByRole('button', { name: 'Keep Earth' })).toBeVisible();
await page.getByRole('tab', { name: 'Bookmarks' }).click();
await expect(page.locator('#dock-panel-bookmarks')).toContainText('Nothing kept yet');
});
test('keeps the system the view is inside, and flies back to it', async ({ page }) => {
test.setTimeout(120_000);
await page.goto('/?stars=4000');
const searchInput = await openSearch(page);
await searchInput.fill('Proxima Centauri');
await page.getByRole('button', { name: /Proxima Centauri/ }).first().click();
const readout = page.getByTestId('hud-title');
await expect(readout).toHaveText('Proxima Centauri', { timeout: 30_000 });
await page.getByRole('button', { name: 'Keep Proxima Centauri' }).click();
// Back out to the field, then return by what was kept rather than by searching again.
await page.getByRole('button', { name: 'Solar Neighbourhood' }).click();
await expect(readout).toHaveText('Local Stars', { timeout: 30_000 });
await page.getByRole('tab', { name: 'Bookmarks' }).click();
await page.locator('#dock-panel-bookmarks li').filter({ hasText: 'Proxima Centauri' }).getByRole('button').first().click();
await expect(readout).toHaveText('Proxima Centauri', { timeout: 45_000 });
});
});
+26 -3
View File
@@ -9,8 +9,12 @@ test.describe('Galaxy view', () => {
test('boots the app, initializes the 3D scene, and starts in the galaxy overview (no system controls shown)', async ({ page }) => { test('boots the app, initializes the 3D scene, and starts in the galaxy overview (no system controls shown)', async ({ page }) => {
await page.goto('/?stars=4000'); await page.goto('/?stars=4000');
await expect(page.getByTestId('scene-canvas')).toBeVisible(); // Generously timed on purpose: this suite shares one software rasterizer, and the boot
await expect(page.getByPlaceholder('Search stars, planets, exoplanets…')).toBeVisible(); // it waits on is the slowest thing in it. The default five seconds is a coin toss
// under a full parallel run.
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
// The dock's tab strip is up before the scene finishes booting; the search is one tab in it.
await expect(page.getByRole('tab', { name: 'Search' })).toBeVisible();
await expect(backButtonLocator(page)).toHaveCount(0); await expect(backButtonLocator(page)).toHaveCount(0);
// The readout panel's own title, not just the text anywhere on screen: the selected-object // The readout panel's own title, not just the text anywhere on screen: the selected-object
// banner across the top names the same thing, so a bare text match is ambiguous. // banner across the top names the same thing, so a bare text match is ambiguous.
@@ -22,7 +26,7 @@ test.describe('Galaxy view', () => {
// up to more than the default per-test budget. // up to more than the default per-test budget.
test.setTimeout(90_000); test.setTimeout(90_000);
await page.goto('/?stars=4000'); await page.goto('/?stars=4000');
await expect(page.getByTestId('scene-canvas')).toBeVisible(); await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'Milky Way' }).click(); await page.getByRole('button', { name: 'Milky Way' }).click();
@@ -36,4 +40,23 @@ test.describe('Galaxy view', () => {
await page.getByRole('button', { name: 'Solar Neighbourhood' }).click(); await page.getByRole('button', { name: 'Solar Neighbourhood' }).click();
await expect(page.getByTestId('hud-title')).toHaveText('Local Stars', { timeout: 15_000 }); await expect(page.getByTestId('hud-title')).toHaveText('Local Stars', { timeout: 15_000 });
}); });
test('a scale bar and labelled rings say how far things are, and follow the zoom', async ({ page }) => {
test.setTimeout(90_000);
await page.goto('/?stars=4000');
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
// The rings are distances from the Sun, the survey's own edge called out among them.
await expect(page.getByText('Survey edge')).toBeVisible({ timeout: 30_000 });
const scale = page.getByTestId('hud-scale');
await expect(scale).toHaveAttribute('aria-label', /^Scale: [\d.]+ k?pc$/);
const opening = await scale.getAttribute('aria-label');
// Zooming in shortens the round length the bar stands for.
await page.getByTestId('scene-canvas').hover();
for (let notch = 0; notch < 10; notch++) {
await page.mouse.wheel(0, -400);
}
await expect(scale).not.toHaveAttribute('aria-label', opening ?? '', { timeout: 15_000 });
});
}); });
+37
View File
@@ -0,0 +1,37 @@
import { expect, test } from '@playwright/test';
import { openSearch } from './support/open-search';
test.describe('Neighbour jump', () => {
test('a neighbour named from inside one system flies into that one', async ({ page }) => {
// Two full camera flights on a software rasterizer shared with the rest of the suite: into
// Sol, then out and into the star its label names. See the same note on camera-flight.
test.setTimeout(120_000);
await page.goto('/?stars=4000');
const searchInput = await openSearch(page);
await searchInput.fill('Sol');
await page.getByRole('button', { name: /^Sol\b/ }).first().click();
const readout = page.getByTestId('hud-title');
await expect(readout).toHaveText('Sol', { timeout: 30_000 });
// Its nearest neighbours are named around the edge of the view; each is a button that flies
// there. Barnard's Star by name, on purpose: the ring ranks named stars ahead of survey
// designations, and the four nearest named to the Sun — Proxima, Rigil Kentaurus, Toliman,
// Barnard's — are a fact about space, not about which catalogue was refreshed last. Before
// that preference, a Gaia row duplicating Proxima took fourth place and this very label was
// the one that vanished, so it is also the regression test for it. Barnard's rather than the
// Alpha Centauri trio because those three share one bearing and are decluttered down to
// whichever the label pass reaches first.
const neighbour = page.getByRole('button', { name: /Barnard's Star/ });
await expect(neighbour).toBeVisible({ timeout: 30_000 });
await neighbour.click();
await expect(readout).toHaveText("Barnard's Star", { timeout: 45_000 });
// And from there the walk goes on: the new system names its own neighbours. Which ones is
// not asserted — several of Barnard's nearest share a bearing, so which of them survives
// the declutter is a property of the view, not a fact about the catalogue.
await expect(page.locator('.map-label--ghost')).not.toHaveCount(0, { timeout: 30_000 });
});
});
+66
View File
@@ -0,0 +1,66 @@
import { expect, test } from '@playwright/test';
import { openSearch } from './support/open-search';
test.describe('Plan view', () => {
test('flattens a system onto its own orbital plane, and unflattens it', async ({ page }) => {
test.setTimeout(120_000);
await page.goto('/?stars=4000');
const searchInput = await openSearch(page);
await searchInput.fill('Sol');
await page.getByRole('button', { name: /^Sol\b/ }).first().click();
await expect(page.getByTestId('hud-title')).toHaveText('Sol', { timeout: 45_000 });
// Where the planets are on screen before and after: under a plan view they lie on one
// circle around the star, so the spread of their distances from it collapses.
const spread = async () =>
page.evaluate(() => {
const labels = [...document.querySelectorAll('.map-label:not(.map-label--ghost)')];
const centre = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
const radii = labels.map((label) => {
const box = label.getBoundingClientRect();
return Math.hypot(box.left - centre.x, box.top + box.height / 2 - centre.y);
});
return radii.length;
});
await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2);
await page.getByRole('tab', { name: 'Display' }).click();
const plan = page.getByRole('button', { name: 'Plan view', exact: true });
await expect(plan).toHaveAttribute('aria-pressed', 'false');
await plan.click();
await expect(plan).toHaveAttribute('aria-pressed', 'true');
// The scene survives the swap: it is still this system, still labelled, still readable.
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
// it waits on is the slowest thing in it. The default five seconds is a coin toss
// under a full parallel run.
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2);
await page.getByRole('tab', { name: 'Readout' }).click();
await expect(page.getByTestId('hud-title')).toHaveText('Sol');
await page.getByRole('tab', { name: 'Display' }).click();
await plan.click();
await expect(plan).toHaveAttribute('aria-pressed', 'false');
await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2);
});
test('keeps the scale ladder honest about how far out the view is', async ({ page }) => {
test.setTimeout(90_000);
await page.goto('/?stars=4000');
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('hud-current-level')).toHaveText('Solar Neighbourhood', { timeout: 30_000 });
await page.getByRole('tab', { name: 'Display' }).click();
await page.getByRole('button', { name: 'Plan view', exact: true }).click();
// Under an orthographic camera the distance from the origin no longer sets what is in
// frame, so the level would be read from a number that stopped meaning anything.
await expect(page.getByTestId('hud-current-level')).toHaveText('Solar Neighbourhood');
await page.getByRole('button', { name: 'Milky Way' }).click();
await expect(page.getByTestId('hud-current-level')).toHaveText('Milky Way', { timeout: 45_000 });
});
});
+64
View File
@@ -0,0 +1,64 @@
import { expect, test } from '@playwright/test';
test.describe('Route plotting', () => {
test('chains one star to another through the crossings a chosen range allows', async ({ page }) => {
test.setTimeout(90_000);
await page.goto('/?stars=4000');
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
// it waits on is the slowest thing in it. The default five seconds is a coin toss
// under a full parallel run.
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: 'Routes' }).click();
// Sirius is 2.64 pc from the Sun, so the default 3 pc range crosses it in one.
await page.locator('#route-from').fill('Sol');
const departure = page.locator('#dock-panel-routes ul li button').first();
await expect(departure).toBeVisible({ timeout: 30_000 });
await departure.click();
await page.locator('#route-to').fill('Sirius');
const destination = page.locator('#dock-panel-routes ul li button').first();
await expect(destination).toBeVisible();
await destination.click();
await page.getByRole('button', { name: 'Plot route' }).click();
await expect(page.getByTestId('route-summary')).toHaveText(/1 jump · 2\.6\d pc/);
// The chain itself, departure first, each step a way to fly there.
const steps = page.getByTestId('route-steps').getByRole('button');
await expect(steps).toHaveCount(2);
await expect(steps.first()).toContainText('Sol');
await expect(steps.last()).toContainText('Sirius');
});
test('says what range a crossing would need, rather than only that there is none', async ({ page }) => {
test.setTimeout(90_000);
await page.goto('/?stars=4000');
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: 'Routes' }).click();
await page.locator('#route-from').fill('Sol');
const departure = page.locator('#dock-panel-routes ul li button').first();
await expect(departure).toBeVisible({ timeout: 30_000 });
await departure.click();
// Narrowed until nothing the catalogue holds is within reach of the Sun: its nearest
// neighbour is 1.30 pc away, so half a parsec strands it.
await page.locator('#route-range').fill('0.5');
await page.locator('#route-to').fill('Sirius');
const destination = page.locator('#dock-panel-routes ul li button').first();
await expect(destination).toBeVisible();
await destination.click();
await page.getByRole('button', { name: 'Plot route' }).click();
const summary = page.getByTestId('route-summary');
await expect(summary).toContainText('No route at this range', { timeout: 30_000 });
// And the answer, not just the refusal: the range that would open one, offered as a control.
const raise = summary.getByRole('button');
await expect(raise).toContainText(/pc would reach/);
await raise.click();
await expect(page.getByTestId('route-summary')).toContainText(/jump/, { timeout: 30_000 });
});
});
+8 -5
View File
@@ -1,25 +1,28 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { openSearch } from './support/open-search';
import { backButtonLocator } from './support/wait-for-back-button'; import { backButtonLocator } from './support/wait-for-back-button';
test.describe('Search-driven navigation', () => { test.describe('Search-driven navigation', () => {
test('selecting a star result flies into that system and shows the back-to-galaxy control', async ({ page }) => { test('selecting a star result flies into that system and shows the back-to-galaxy control', async ({ page }) => {
await page.goto('/?stars=4000'); await page.goto('/?stars=4000');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…'); const searchInput = await openSearch(page);
await searchInput.fill('Proxima Centauri'); await searchInput.fill('Proxima Centauri');
const result = page.getByRole('button', { name: /Proxima Centauri/ }); const result = page.getByRole('button', { name: /Proxima Centauri/ });
await expect(result).toBeVisible(); await expect(result).toBeVisible();
await result.click(); await result.click();
// Selecting a result clears the search query immediately (before the flight even starts). // Selecting a result hands the dock straight back to the readout (before the flight even
await expect(searchInput).toHaveValue(''); // starts): the thing to look at is now the scene, and the search panel folds away with it.
await expect(page.getByRole('tab', { name: 'Readout' })).toHaveAttribute('aria-selected', 'true');
await expect(searchInput).toHaveCount(0);
await expect(backButtonLocator(page)).toBeVisible({ timeout: 15_000 }); await expect(backButtonLocator(page)).toBeVisible({ timeout: 15_000 });
}); });
test('selecting a body result navigates straight to its detail route and shows real NASA data', async ({ page }) => { test('selecting a body result navigates straight to its detail route and shows real NASA data', async ({ page }) => {
await page.goto('/?stars=4000'); await page.goto('/?stars=4000');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…'); const searchInput = await openSearch(page);
await searchInput.fill('Earth'); await searchInput.fill('Earth');
const result = page.getByText('Earth', { exact: true }); const result = page.getByText('Earth', { exact: true });
@@ -36,7 +39,7 @@ test.describe('Search-driven navigation', () => {
test('typing fewer than two characters shows no results, and Escape clears the query', async ({ page }) => { test('typing fewer than two characters shows no results, and Escape clears the query', async ({ page }) => {
await page.goto('/?stars=4000'); await page.goto('/?stars=4000');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…'); const searchInput = await openSearch(page);
await searchInput.fill('E'); await searchInput.fill('E');
await expect(page.getByTestId('search-results')).toHaveCount(0); await expect(page.getByTestId('search-results')).toHaveCount(0);
+14
View File
@@ -0,0 +1,14 @@
import { Locator, Page } from '@playwright/test';
/**
* The search lives in the dock along the bottom, behind its own tab, so a test that wants to
* type has to open it first — the way a user does, or with the `/` shortcut. Returns the field.
*/
export async function openSearch(page: Page): Promise<Locator> {
// Clicking the active tab folds it closed, so only click when it is not already open.
const tab = page.getByRole('tab', { name: 'Search' });
if ((await tab.getAttribute('aria-selected')) !== 'true') {
await tab.click();
}
return page.getByPlaceholder('Search stars, planets, exoplanets…');
}
+2 -1
View File
@@ -10,7 +10,8 @@
"etl": "tsx tools/etl/build.ts", "etl": "tsx tools/etl/build.ts",
"etl:typecheck": "tsc -p tools/etl/tsconfig.json --noEmit", "etl:typecheck": "tsc -p tools/etl/tsconfig.json --noEmit",
"e2e": "playwright test", "e2e": "playwright test",
"e2e:typecheck": "tsc -p e2e/tsconfig.json --noEmit" "e2e:typecheck": "tsc -p e2e/tsconfig.json --noEmit",
"worker:typecheck": "tsc -p tsconfig.worker.json --noEmit"
}, },
"private": true, "private": true,
"packageManager": "npm@11.12.1", "packageManager": "npm@11.12.1",
+6
View File
@@ -11,6 +11,12 @@ export default defineConfig({
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
reporter: 'html', reporter: 'html',
// Well above Playwright's 5 s, which was a fair ceiling when the star catalogue was 820 kB and
// is not now that the scheduled refresh has it at 5.4 MB and 447 410 rows: every one of these
// tests boots that catalogue, and the suite boots several at once on a software rasterizer.
// The heavy waits already carry their own longer timeouts; this is the same judgement applied
// to the assertions that were left on the default. A ceiling costs nothing when it is not hit.
expect: { timeout: 15_000 },
use: { use: {
baseURL: 'http://localhost:4300', baseURL: 'http://localhost:4300',
trace: 'on-first-retry' trace: 'on-first-retry'
-1
View File
@@ -4,5 +4,4 @@
aria-hidden="true" aria-hidden="true"
class="hud-brackets pointer-events-none fixed inset-2 z-30 [--hud-tick:1.75rem]" class="hud-brackets pointer-events-none fixed inset-2 z-30 [--hud-tick:1.75rem]"
></div> ></div>
<app-search></app-search>
<router-outlet></router-outlet> <router-outlet></router-outlet>
+1 -3
View File
@@ -1,11 +1,9 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { SearchComponent } from './features/search/search.component';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, SearchComponent], imports: [RouterOutlet],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss' styleUrl: './app.scss'
}) })
+159
View File
@@ -0,0 +1,159 @@
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ArticleService } from './article.service';
function summary(overrides: Record<string, unknown> = {}): Response {
return new Response(
JSON.stringify({
type: 'standard',
titles: { normalized: 'Titan (moon)' },
extract: 'Titan is the largest moon of Saturn.',
content_urls: { desktop: { page: 'https://en.wikipedia.org/wiki/Titan_(moon)' } },
...overrides
}),
{ status: 200 }
);
}
function missing(): Response {
return new Response('{}', { status: 404 });
}
/** What Wikipedia sends anyone who asks too quickly. */
function rateLimited(): Response {
return new Response('You are making too many requests to the API.', { status: 429 });
}
function service(): ArticleService {
TestBed.resetTestingModule();
return TestBed.inject(ArticleService);
}
/** The language each call was made in, in order, so the fallback chain can be asserted. */
function languagesAsked(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls.map((call) => new URL(String(call[0])).hostname.split('.')[0]);
}
function titlesAsked(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls.map((call) => decodeURIComponent(String(call[0]).split('/summary/')[1]));
}
describe('ArticleService', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('navigator', { language: 'en-GB' });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('hands back what Wikipedia wrote, and where to go and check it', async () => {
fetchMock.mockResolvedValue(summary());
const result = await service().lookup('Titan');
expect(result).toEqual({
status: 'found',
article: {
title: 'Titan (moon)',
extract: 'Titan is the largest moon of Saturn.',
url: 'https://en.wikipedia.org/wiki/Titan_(moon)',
language: 'en'
}
});
});
it('asks in the browser’s language first and English second', async () => {
vi.stubGlobal('navigator', { language: 'fr-FR' });
fetchMock.mockResolvedValueOnce(missing()).mockResolvedValueOnce(summary());
const result = await service().lookup('Titan');
expect(languagesAsked(fetchMock)).toEqual(['fr', 'en']);
expect(result.status).toBe('found');
});
it('asks English only once, when English is what the browser is set to', async () => {
fetchMock.mockResolvedValue(summary());
await service().lookup('Titan');
expect(languagesAsked(fetchMock)).toEqual(['en']);
});
it('names the kind when the plain name lands on a list of other things', async () => {
fetchMock.mockResolvedValueOnce(summary({ type: 'disambiguation' })).mockResolvedValueOnce(summary());
const result = await service().lookup('Titan', 'moon');
expect(titlesAsked(fetchMock)).toEqual(['Titan', 'Titan (moon)']);
expect(result.status).toBe('found');
});
it('says there is nothing written rather than pretending, when nothing is', async () => {
fetchMock.mockResolvedValue(missing());
expect(await service().lookup('HD 224700', 'planet')).toEqual({ status: 'none' });
});
it('tells being unable to ask apart from there being no answer', async () => {
fetchMock.mockRejectedValue(new TypeError('offline'));
expect(await service().lookup('Titan')).toEqual({ status: 'unavailable' });
});
it('counts the rate limit as being unable to ask, not as an empty answer', async () => {
fetchMock.mockResolvedValue(rateLimited());
expect(await service().lookup('Titan')).toEqual({ status: 'unavailable' });
});
it('asks once per body, however many times it is asked for', async () => {
fetchMock.mockResolvedValue(summary());
const articles = service();
await articles.lookup('Titan');
await articles.lookup('Titan');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('remembers that there is nothing, but never that it could not ask', async () => {
const articles = service();
fetchMock.mockResolvedValue(missing());
await articles.lookup('Nowhere');
await articles.lookup('Nowhere');
const afterMissing = fetchMock.mock.calls.length;
fetchMock.mockRejectedValue(new TypeError('offline'));
await articles.lookup('Elsewhere');
const afterFirstFailure = fetchMock.mock.calls.length;
await articles.lookup('Elsewhere');
// One round of requests for the missing page, then nothing more; but a failure to reach
// Wikipedia is a fact about this minute, so pressing again is allowed to try again.
expect(afterMissing).toBe(1);
expect(fetchMock.mock.calls.length).toBeGreaterThan(afterFirstFailure);
});
it('treats an article with nothing in it as no article', async () => {
fetchMock.mockResolvedValue(summary({ extract: ' ' }));
expect(await service().lookup('Titan')).toEqual({ status: 'none' });
});
it('does not send a browser language that is not one', async () => {
vi.stubGlobal('navigator', { language: 'not a language tag' });
fetchMock.mockResolvedValue(summary());
await service().lookup('Titan');
expect(languagesAsked(fetchMock)).toEqual(['en']);
});
});
+138
View File
@@ -0,0 +1,138 @@
import { Injectable } from '@angular/core';
/** A Wikipedia summary, as much of it as this app shows. */
export interface Article {
readonly title: string;
readonly extract: string;
/** The article itself, so a reader can go and check. */
readonly url: string;
/** Which Wikipedia it came from — `en`, `fr`. Shown, because it is not always the one asked for. */
readonly language: string;
}
/**
* Found it, there is no article, or Wikipedia could not be reached. The last two are different
* facts and the panel says so: "nothing written about this" and "could not ask" are not the
* same, and only one of them is about the world.
*/
export type ArticleLookup = { readonly status: 'found'; readonly article: Article } | { readonly status: 'none' } | { readonly status: 'unavailable' };
const FALLBACK_LANGUAGE = 'en';
/** Wikipedia's own summary endpoint, which follows redirects — "Proxima Cen b" lands on
* "Proxima Centauri b" without this app having to know that. */
function summaryUrl(language: string, title: string): string {
return `https://${language}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`;
}
/** The primary subtag of whatever the browser is set to, or English where there is no browser. */
function browserLanguage(): string {
// `navigator.language` is optional in the DOM lib and absent in some embedded engines.
const tag = (typeof navigator === 'undefined' ? '' : navigator.language) ?? '';
const primary = tag.split('-')[0]?.toLowerCase();
return primary && /^[a-z]{2,3}$/.test(primary) ? primary : FALLBACK_LANGUAGE;
}
/**
* Wikipedia summaries, fetched only when asked for.
*
* The map's own figures are measurements; this is prose someone wrote, from somewhere else. It
* is never loaded with a body, only when a reader asks for it, and it is always labelled with
* where it came from and linked back to the article — the same rule the derived surfaces follow,
* for the same reason.
*
* Asked for in the browser's language first and English second, because the catalogue's names
* are English and the disambiguation this has to work around is an English-Wikipedia habit.
*/
@Injectable({ providedIn: 'root' })
export class ArticleService {
private readonly cache = new Map<string, ArticleLookup>();
/**
* The article for `name`, or why there is none.
*
* `qualifier` is what tells "Titan the moon" from "Titan the disambiguation page": Wikipedia
* parenthesises the kind, and this app happens to know it. Only English gets that treatment —
* every wiki words its own qualifiers — so a disambiguation anywhere else falls back to
* English rather than guessing at a translation.
*/
async lookup(name: string, qualifier?: string): Promise<ArticleLookup> {
// Separated by an escape rather than a space: a name may contain one, so `("Kepler-22 b",
// undefined)` and `("Kepler-22", "b")` would otherwise be the same question.
const key = `${name}\0${qualifier ?? ''}`;
const cached = this.cache.get(key);
if (cached) {
return cached;
}
const language = browserLanguage();
const attempts: { language: string; title: string }[] = [
...(language === FALLBACK_LANGUAGE ? [] : [{ language, title: name }]),
{ language: FALLBACK_LANGUAGE, title: name },
...(qualifier ? [{ language: FALLBACK_LANGUAGE, title: `${name} (${qualifier})` }] : [])
];
let reachedWikipedia = false;
for (const attempt of attempts) {
const result = await this.fetchSummary(attempt.language, attempt.title);
if (result === 'unreachable') {
continue;
}
reachedWikipedia = true;
if (result) {
const found: ArticleLookup = { status: 'found', article: result };
this.cache.set(key, found);
return found;
}
}
// Not cached when Wikipedia could not be reached: that is a fact about the network this
// minute, and the next press should be allowed to ask again.
const outcome: ArticleLookup = reachedWikipedia ? { status: 'none' } : { status: 'unavailable' };
if (reachedWikipedia) {
this.cache.set(key, outcome);
}
return outcome;
}
/**
* One request. `null` where Wikipedia answered but has nothing usable — a missing page, or a
* disambiguation, which is a list of things this is not. `'unreachable'` where it did not
* answer at all, including the rate limit it returns to anyone who asks too fast.
*/
private async fetchSummary(language: string, title: string): Promise<Article | null | 'unreachable'> {
let response: Response;
try {
response = await fetch(summaryUrl(language, title), { headers: { Accept: 'application/json' } });
} catch {
return 'unreachable';
}
if (response.status === 404) {
return null;
}
if (!response.ok) {
return 'unreachable';
}
try {
const body = (await response.json()) as {
type?: string;
extract?: string;
titles?: { normalized?: string };
content_urls?: { desktop?: { page?: string } };
};
const extract = body.extract?.trim();
if (!extract || body.type === 'disambiguation') {
return null;
}
return {
title: body.titles?.normalized ?? title,
extract,
url: body.content_urls?.desktop?.page ?? `https://${language}.wikipedia.org/wiki/${encodeURIComponent(title)}`,
language
};
} catch {
return 'unreachable';
}
}
}
+109
View File
@@ -0,0 +1,109 @@
import { NgZone } from '@angular/core';
import * as THREE from 'three/webgpu';
import { beforeEach, describe, expect, it } from 'vitest';
import { EngineService } from './engine.service';
/**
* The projection half of the engine, which is the half that can be tested without a GPU: no
* renderer is created, the two cameras are placed by hand, and what is asserted is the
* arithmetic that keeps them showing the same thing.
*/
function engineWithCameras(): { engine: EngineService; perspective: THREE.PerspectiveCamera; orthographic: THREE.OrthographicCamera } {
const engine = new EngineService({ runOutsideAngular: (fn: () => unknown) => fn() } as unknown as NgZone);
const perspective = new THREE.PerspectiveCamera(50, 16 / 9, 0.1, 1000);
const orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
// The two cameras are private, because nothing outside should choose between them by hand.
Object.assign(engine as unknown as Record<string, unknown>, { perspective, orthographic });
return { engine, perspective, orthographic };
}
/** Half the height of what a perspective camera frames at `distance`, in world units. */
function perspectiveHalfHeight(camera: THREE.PerspectiveCamera, distance: number): number {
return distance * Math.tan((camera.fov * Math.PI) / 360);
}
describe('EngineService projection', () => {
let engine: EngineService;
let perspective: THREE.PerspectiveCamera;
let orthographic: THREE.OrthographicCamera;
beforeEach(() => {
({ engine, perspective, orthographic } = engineWithCameras());
});
it('draws through the perspective camera until told otherwise', () => {
expect(engine.currentProjection).toBe('perspective');
expect(engine.getCamera()).toBe(perspective);
});
it('frames the same extent through either camera, which is the point of the swap', () => {
perspective.position.set(0, 0, 200);
engine.setProjection('orthographic', 200);
expect(engine.getCamera()).toBe(orthographic);
expect(engine.visibleHalfHeight(200)).toBeCloseTo(perspectiveHalfHeight(perspective, 200), 6);
// And as wide as the frame is, not as wide as it is tall.
expect(orthographic.right - orthographic.left).toBeCloseTo((orthographic.top - orthographic.bottom) * perspective.aspect, 6);
});
it('carries the pose across, so the swap changes the projection and not the view', () => {
perspective.position.set(3, 4, 12);
perspective.lookAt(0, 0, 0);
engine.setProjection('orthographic', 13);
expect(orthographic.position.toArray()).toEqual(perspective.position.toArray());
expect(orthographic.quaternion.toArray()).toEqual(perspective.quaternion.toArray());
});
it('sees behind itself, because a parallel camera does not back away from what it frames', () => {
engine.setProjection('orthographic', 100);
// A perspective camera pulls back as its frame grows and leaves the scene in front of it. An
// orthographic one does not move at all, so half the Galaxy ends up behind its own plane —
// and a near plane in front would clip it away. Parallel depth is linear, so the precision
// that a perspective near plane is guarding for does not apply.
expect(orthographic.near).toBe(-perspective.far);
expect(orthographic.far).toBe(perspective.far);
});
it('goes back, and hands out the perspective camera again', () => {
engine.setProjection('orthographic', 100);
engine.setProjection('perspective', 100);
expect(engine.currentProjection).toBe('perspective');
expect(engine.getCamera()).toBe(perspective);
expect(engine.visibleHalfHeight(100)).toBeCloseTo(perspectiveHalfHeight(perspective, 100), 6);
});
it('reports the extent the orthographic camera is zoomed to, not the one it was built at', () => {
engine.setProjection('orthographic', 100);
const framed = engine.visibleHalfHeight(100);
orthographic.zoom = 2;
// Zoomed in twice: half as much in frame. Distance says nothing about it, which is why
// nothing downstream may read the camera's distance under this projection.
expect(engine.visibleHalfHeight(100)).toBeCloseTo(framed / 2, 6);
expect(engine.visibleHalfHeight(999)).toBeCloseTo(framed / 2, 6);
});
it('never divides by a camera sitting on its own target', () => {
expect(() => engine.setProjection('orthographic', 0)).not.toThrow();
expect(Number.isFinite(orthographic.top)).toBe(true);
});
it('widens rather than magnifies when the window gets wider', () => {
engine.setProjection('orthographic', 100);
const height = orthographic.top - orthographic.bottom;
// No renderer, so resize returns early — the frustum is re-fitted by hand the same way.
orthographic.left = (-height / 2) * (21 / 9);
orthographic.right = (height / 2) * (21 / 9);
expect(orthographic.top - orthographic.bottom).toBeCloseTo(height, 6);
expect(orthographic.right - orthographic.left).toBeCloseTo(height * (21 / 9), 6);
});
});
+96 -10
View File
@@ -3,6 +3,12 @@ import * as THREE from 'three/webgpu';
export type EngineTickCallback = (deltaSeconds: number, elapsedSeconds: number) => void; export type EngineTickCallback = (deltaSeconds: number, elapsedSeconds: number) => void;
/** Which projection the scene is drawn through. */
export type Projection = 'perspective' | 'orthographic';
/** Either camera, as everything downstream of the projection sees it. */
export type SceneCamera = THREE.PerspectiveCamera | THREE.OrthographicCamera;
/** /**
* Owns the Three.js WebGPURenderer (with automatic WebGL2 fallback), the base scene/camera, * Owns the Three.js WebGPURenderer (with automatic WebGL2 fallback), the base scene/camera,
* and the render loop. The loop always runs outside Angular's zone so per-frame work never * and the render loop. The loop always runs outside Angular's zone so per-frame work never
@@ -20,7 +26,14 @@ export class EngineService {
private canvas?: HTMLCanvasElement; private canvas?: HTMLCanvasElement;
private renderer?: THREE.WebGPURenderer; private renderer?: THREE.WebGPURenderer;
private scene?: THREE.Scene; private scene?: THREE.Scene;
private camera?: THREE.PerspectiveCamera; private perspective?: THREE.PerspectiveCamera;
/**
* Built alongside the perspective one and kept in step with it, rather than made on demand:
* the two share a position, an orientation and a depth range, and a camera that only exists
* while it is being looked through is a camera whose state is always one swap out of date.
*/
private orthographic?: THREE.OrthographicCamera;
private projection: Projection = 'perspective';
private running = false; private running = false;
constructor(private readonly ngZone: NgZone) {} constructor(private readonly ngZone: NgZone) {}
@@ -33,8 +46,71 @@ export class EngineService {
return this.requireInitialized(this.scene); return this.requireInitialized(this.scene);
} }
getCamera(): THREE.PerspectiveCamera { /** The camera the scene is currently drawn through. */
return this.requireInitialized(this.camera); getCamera(): SceneCamera {
return this.projection === 'orthographic' ? this.requireInitialized(this.orthographic) : this.requireInitialized(this.perspective);
}
/**
* The perspective camera, whichever is active. For the handful of places that need a field of
* view to reason with — framing a system, sizing a star — and that go on meaning the same
* thing in either projection because the sizes were tuned against this one.
*/
getPerspectiveCamera(): THREE.PerspectiveCamera {
return this.requireInitialized(this.perspective);
}
get currentProjection(): Projection {
return this.projection;
}
/**
* Switches projection, carrying the pose across. The orthographic frustum is sized to show
* the same extent at `distanceToTarget` that the perspective camera showed from there, so the
* swap changes how the scene is projected and not how much of it is in frame.
*/
setProjection(projection: Projection, distanceToTarget: number): void {
const perspective = this.requireInitialized(this.perspective);
const orthographic = this.requireInitialized(this.orthographic);
this.projection = projection;
orthographic.zoom = 1;
orthographic.position.copy(perspective.position);
orthographic.quaternion.copy(perspective.quaternion);
this.frameOrthographic(distanceToTarget);
}
/**
* Sizes the orthographic frustum to show, at `distanceToTarget`, what the perspective camera
* would show from there. Called every frame while that projection is active, which is what
* makes the camera flights work through it: they move the camera, and the frame follows.
*
* The depth range is symmetric about the camera rather than starting in front of it. An
* orthographic camera does not pull back as its frame grows, so at galactic framing the
* backdrop shell and half the Milky Way lie behind its own plane and would be clipped away.
* A parallel projection has linear depth, so the precision argument that makes a perspective
* near plane worth guarding does not apply here.
*/
frameOrthographic(distanceToTarget: number): void {
const perspective = this.requireInitialized(this.perspective);
const orthographic = this.requireInitialized(this.orthographic);
const halfHeight = Math.max(distanceToTarget, 1e-6) * Math.tan((perspective.fov * Math.PI) / 360);
orthographic.top = halfHeight;
orthographic.bottom = -halfHeight;
orthographic.left = -halfHeight * perspective.aspect;
orthographic.right = halfHeight * perspective.aspect;
orthographic.far = perspective.far;
orthographic.near = -perspective.far;
orthographic.updateProjectionMatrix();
}
/** Half the height of what is in frame at the target, in world units, under either camera. */
visibleHalfHeight(distanceToTarget: number): number {
if (this.projection === 'orthographic') {
const orthographic = this.requireInitialized(this.orthographic);
return (orthographic.top - orthographic.bottom) / (2 * orthographic.zoom);
}
return distanceToTarget * Math.tan((this.requireInitialized(this.perspective).fov * Math.PI) / 360);
} }
getRenderer(): THREE.WebGPURenderer { getRenderer(): THREE.WebGPURenderer {
@@ -52,8 +128,10 @@ export class EngineService {
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.scene = new THREE.Scene(); this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000); this.perspective = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
this.camera.position.set(0, 0, 5); this.perspective.position.set(0, 0, 5);
this.orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
this.orthographic.position.copy(this.perspective.position);
const { width, height } = this.canvasSize(); const { width, height } = this.canvasSize();
this.resize(width, height); this.resize(width, height);
@@ -99,11 +177,18 @@ export class EngineService {
* Updates the camera aspect ratio and renderer drawing buffer size. * Updates the camera aspect ratio and renderer drawing buffer size.
*/ */
resize(width: number, height: number): void { resize(width: number, height: number): void {
if (!this.renderer || !this.camera || width <= 0 || height <= 0) { if (!this.renderer || !this.perspective || !this.orthographic || width <= 0 || height <= 0) {
return; return;
} }
this.camera.aspect = width / height; const aspect = width / height;
this.camera.updateProjectionMatrix(); this.perspective.aspect = aspect;
this.perspective.updateProjectionMatrix();
// The orthographic frustum keeps its height and re-fits its width, so a window getting wider
// shows more to the sides rather than magnifying what was already there.
const halfHeight = (this.orthographic.top - this.orthographic.bottom) / 2;
this.orthographic.left = -halfHeight * aspect;
this.orthographic.right = halfHeight * aspect;
this.orthographic.updateProjectionMatrix();
this.renderer.setSize(width, height, false); this.renderer.setSize(width, height, false);
} }
@@ -116,7 +201,8 @@ export class EngineService {
this.renderer?.dispose(); this.renderer?.dispose();
this.renderer = undefined; this.renderer = undefined;
this.scene = undefined; this.scene = undefined;
this.camera = undefined; this.perspective = undefined;
this.orthographic = undefined;
this.canvas = undefined; this.canvas = undefined;
} }
@@ -128,7 +214,7 @@ export class EngineService {
callback(deltaSeconds, elapsedSeconds); callback(deltaSeconds, elapsedSeconds);
} }
this.requireInitialized(this.renderer).render(this.requireInitialized(this.scene), this.requireInitialized(this.camera)); this.requireInitialized(this.renderer).render(this.requireInitialized(this.scene), this.getCamera());
} }
private canvasSize(): { width: number; height: number } { private canvasSize(): { width: number; height: number } {
@@ -0,0 +1,285 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import * as THREE from 'three/webgpu';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DataLoaderService, StarField } from '../../core/data/data-loader.service';
import { EngineService, EngineTickCallback } from '../../core/engine/engine.service';
import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model';
import { bodyPageView } from '../../shared/rendering/body-orientation';
import { TimeStore } from '../../shared/state/time.store';
import { BodyDetailSceneComponent } from './body-detail-scene.component';
// jsdom has no ResizeObserver; the page only uses it to follow real layout changes.
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver ??= class {
observe(): void {}
disconnect(): void {}
};
const SUN: StarRecord = { id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 };
// Earth and Saturn as bodies.json carries them: Standish's elements and the IAU's.
const EARTH: BodyRecord = {
id: 'earth', systemStarId: 0, name: 'Earth', kind: 'planet', radiusKm: 6371, orbitSource: 'test',
orbit: {semiMajorAxisAu: 1.00000018, eccentricity: 0.01673163, inclinationDeg: -0.00054346, longitudeOfAscendingNodeDeg: -5.11260389, argumentOfPeriapsisDeg: 108.04266274, meanAnomalyAtEpochDeg: -2.4631431299999917, epochJd: 2451545},
rates: {meanMotionDegPerDay: 0.9856091187759068, longitudeOfAscendingNodeDegPerDay: -0.000006604751813826146, argumentOfPeriapsisDegPerDay: 0.000015309819575633124},
rotationalElements: {poleRaDeg: [0, -0.641, 0], poleDecDeg: [90, -0.557, 0], primeMeridianDeg: [190.147, 360.9856235, 0]}
};
const SATURN: BodyRecord = {
id: 'saturn', systemStarId: 0, name: 'Saturn', kind: 'planet', radiusKm: 58232, orbitSource: 'test',
orbit: {semiMajorAxisAu: 9.54149883, eccentricity: 0.05550825, inclinationDeg: 2.49424102, longitudeOfAscendingNodeDeg: 113.63998702, argumentOfPeriapsisDeg: -20.778626390000014, meanAnomalyAtEpochDeg: -42.78564733999999, epochJd: 2451545},
rates: {meanMotionDegPerDay: 0.033459683702669406, longitudeOfAscendingNodeDegPerDay: -0.000006848734291581108, argumentOfPeriapsisDegPerDay: 0.000021682266940451745},
rotationalElements: {poleRaDeg: [40.589, -0.036, 0], poleDecDeg: [83.537, -0.004, 0], primeMeridianDeg: [38.9, 810.7939024, 0]}
};
// Eris and Hyperion as they are shipped for this page's purposes: no IAU model, so their pages keep
// their own light; Eris's day is measured, and Hyperion tumbles and has none.
const ERIS: BodyRecord = { ...EARTH, id: 'eris', name: 'Eris', kind: 'dwarf', radiusKm: 1163, rotationalElements: undefined, rotationPeriodHours: 378.504 };
const HYPERION: BodyRecord = { ...ERIS, id: 'hyperion', name: 'Hyperion', kind: 'moon', radiusKm: 135, parentBodyId: 'saturn', rotationPeriodHours: undefined };
// Mercury as shipped, but its 0.01-degree libration: its Sun is never 0.034 degrees off its equator.
const MERCURY: BodyRecord = {
id: 'mercury', systemStarId: 0, name: 'Mercury', kind: 'planet', radiusKm: 2439.4, orbitSource: 'test',
orbit: {semiMajorAxisAu: 0.38709843, eccentricity: 0.20563661, inclinationDeg: 7.00559432, longitudeOfAscendingNodeDeg: 48.33961819, argumentOfPeriapsisDeg: 29.118100759999997, meanAnomalyAtEpochDeg: 174.79394829, epochJd: 2451545},
rates: {meanMotionDegPerDay: 4.092338805372484, longitudeOfAscendingNodeDegPerDay: -0.0000033440607802874744, argumentOfPeriapsisDegPerDay: 0.000007708198494182067},
rotationalElements: {poleRaDeg: [281.0103, -0.0328, 0], poleDecDeg: [61.4155, -0.0049, 0], primeMeridianDeg: [329.5988, 6.1385108, 0]}
};
const BODIES = [EARTH, SATURN, ERIS, HYPERION, MERCURY];
// An exoplanet round the Sun's record, which is all the page needs of its host.
const EXOPLANET: ExoplanetRecord = { id: 'x b', hostStarId: 0, hostStarName: 'Sol', name: 'X b', orbit: { semiMajorAxisAu: 0.05 } };
/** Stands in for the WebGPU engine: a scene, a camera, and the tick hook, driven by hand. */
class FakeEngineService {
private readonly scene = new THREE.Scene();
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
private readonly callbacks = new Set<EngineTickCallback>();
async init(): Promise<void> {}
getScene(): THREE.Scene {
return this.scene;
}
getCamera(): THREE.PerspectiveCamera {
return this.camera;
}
onTick(callback: EngineTickCallback): () => void {
this.callbacks.add(callback);
return () => this.callbacks.delete(callback);
}
start(): void {}
resize(): void {}
dispose(): void {}
tick(deltaSeconds: number): void {
for (const callback of this.callbacks) {
callback(deltaSeconds, 0);
}
}
}
class FakeDataLoaderService {
loadStars(): Promise<StarField> {
return Promise.resolve({ stars: [SUN], positions: new Float32Array([0, 0, 0]) });
}
loadBodies(): Promise<BodyRecord[]> {
return Promise.resolve(BODIES);
}
loadExoplanets(): Promise<ExoplanetRecord[]> {
return Promise.resolve([EXOPLANET]);
}
}
async function flushAsync(turns = 8): Promise<void> {
for (let i = 0; i < turns; i++) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
describe('BodyDetailSceneComponent', () => {
let engine: FakeEngineService;
let page: { planet: THREE.Mesh; ring?: THREE.Mesh; sunLight: THREE.DirectionalLight };
let time: TimeStore;
let route: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let fixture: ComponentFixture<BodyDetailSceneComponent>;
/** Opens a body's page at a date; `whole` keeps the page's own template, dock and panel included. */
async function open(id: string, date = '2025-06-01T12:00Z', whole = false): Promise<void> {
engine = new FakeEngineService();
route = new BehaviorSubject(convertToParamMap({ id }));
TestBed.configureTestingModule({
imports: [BodyDetailSceneComponent],
providers: [
{ provide: DataLoaderService, useClass: FakeDataLoaderService },
{ provide: ActivatedRoute, useValue: { paramMap: route } },
{ provide: Router, useValue: { navigate: vi.fn().mockResolvedValue(true) } }
]
}).overrideComponent(BodyDetailSceneComponent, {
// The scene alone, unless asked: the info panel and the dock are tested on their own.
set: whole ? { providers: [{ provide: EngineService, useValue: engine }] } : { providers: [{ provide: EngineService, useValue: engine }], imports: [], template: '<canvas #canvas></canvas>' }
});
time = TestBed.inject(TimeStore);
time.setRate(0);
time.setDate(new Date(date));
fixture = TestBed.createComponent(BodyDetailSceneComponent);
fixture.detectChanges();
await flushAsync();
page = fixture.componentInstance as unknown as typeof page;
engine.tick(0.016);
}
beforeEach(() => TestBed.resetTestingModule());
it('lays Saturn’s rings in its equator on the page, where audit #47 found them 17 degrees off it', async () => {
await open('saturn');
const ring = page.ring!;
ring.updateWorldMatrix(true, false);
const normal = new THREE.Vector3().fromBufferAttribute(ring.geometry.attributes['normal'], 0).transformDirection(ring.matrixWorld);
const pole = new THREE.Vector3(0, 1, 0).applyQuaternion(page.planet.quaternion);
expect(normal.angleTo(pole)).toBeLessThan(1e-6);
});
it('turns Earth on its page as it stands at the map’s date, under its real Sun', async () => {
await open('earth');
const planet = new THREE.Quaternion();
const sun = new THREE.Vector3();
expect(bodyPageView(EARTH, BODIES, time.julianDate(), Math.atan2(4, 5), planet, sun)).toBe(true);
expect(page.planet.quaternion.angleTo(planet)).toBeLessThan(1e-9);
expect(page.sunLight.position.clone().normalize().angleTo(sun)).toBeLessThan(1e-9);
});
it('follows the clock once the page is open, as it runs or is set', async () => {
await open('earth');
time.setDate(new Date('2025-06-01T18:00Z'));
engine.tick(0.016);
const planet = new THREE.Quaternion();
expect(bodyPageView(EARTH, BODIES, time.julianDate(), Math.atan2(4, 5), planet, new THREE.Vector3())).toBe(true);
// Six hours on, a quarter turn of Earth: a page frozen at its first frame is 90 degrees out.
expect(page.planet.quaternion.angleTo(planet)).toBeLessThan(1e-9);
});
it('puts the page’s own light back when the next body shown has no IAU model to place its Sun', async () => {
await open('earth');
expect(page.sunLight.position.distanceTo(new THREE.Vector3(4, 3, 5))).toBeGreaterThan(0.1);
route.next(convertToParamMap({ id: 'eris' }));
await flushAsync();
engine.tick(0.016);
expect(page.sunLight.position.distanceTo(new THREE.Vector3(4, 3, 5))).toBeLessThan(1e-9);
});
it('puts the sphere back at rest when the next body shown does not turn: Hyperion after Earth', async () => {
await open('earth');
expect(page.planet.quaternion.angleTo(new THREE.Quaternion())).toBeGreaterThan(0.1);
route.next(convertToParamMap({ id: 'hyperion' }));
await flushAsync();
engine.tick(0.016);
engine.tick(0.016);
expect(page.planet.quaternion.angleTo(new THREE.Quaternion())).toBeLessThan(1e-9);
});
it('turns a body whose day is measured but not its pole at that day on the map’s clock: Eris a sixth of a turn in 63.084 hours', async () => {
await open('eris');
const start = page.planet.rotation.y;
// The clock stands (the page is opened at rate 0): so does Eris, where it used to turn for show.
engine.tick(1);
expect(page.planet.rotation.y).toBe(start);
time.setDate(new Date(Date.parse('2025-06-01T12:00Z') + (378.504 / 6) * 3600000));
engine.tick(0.016);
const turned = (((page.planet.rotation.y - start) / (2 * Math.PI)) % 1 + 1) % 1;
expect(turned).toBeCloseTo(1 / 6, 6);
// Pole up, as the system view turns it about its orbit's normal.
expect(new THREE.Vector3(0, 1, 0).applyQuaternion(page.planet.quaternion).y).toBeCloseTo(1, 12);
});
it('turns an exoplanet slowly for show, clock or no clock: the catalogue carries no day for it', async () => {
await open('x b');
const start = page.planet.rotation.y;
// The clock stands; a second of the page's own time is 0.08 radians.
engine.tick(1);
expect(page.planet.rotation.y - start).toBeCloseTo(0.08, 12);
});
it('says on its dock the date the body is drawn for, and nothing at the present, and offers the clock', async () => {
await open('saturn', '2032-06-01T12:00Z', true);
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelector('[data-testid="hud-date"]')?.textContent).toContain('2032-06-01');
expect([...host.querySelectorAll('[role="tab"]')].map((tab) => tab.textContent?.trim())).toContain('Clock');
time.reset();
engine.tick(0.016);
fixture.detectChanges();
expect(host.querySelector('[data-testid="hud-date"]')).toBeNull();
});
it('opens Saturn on the face of its rings the Sun lights: the south, from 2025 to 2039', async () => {
await open('saturn', '2032-06-01T12:00Z');
const camera = engine.getCamera();
// The Sun 26.7 degrees south of the rings, and the camera with it rather than 11 degrees north.
expect(page.sunLight.position.y).toBeLessThan(0);
expect(camera.position.y).toBeLessThan(0);
route.next(convertToParamMap({ id: 'earth' }));
await flushAsync();
engine.tick(0.016);
// June: Earth's Sun is in the north, and so is the camera again.
expect(page.sunLight.position.y).toBeGreaterThan(0);
expect(camera.position.y).toBeGreaterThan(0);
});
it('follows the Sun across Saturn’s equator when the clock is set past the 2039 equinox, and aims at Saturn in that same frame', async () => {
await open('saturn', '2032-06-01T12:00Z');
const camera = engine.getCamera();
expect(camera.position.y).toBeLessThan(0);
// What the page's own Clock tab does: 2045, the Sun 25.7 degrees north of the rings.
time.setDate(new Date('2045-06-01T12:00Z'));
engine.tick(0.016);
expect(page.sunLight.position.y).toBeGreaterThan(0);
expect(camera.position.y).toBeGreaterThan(0);
// The frame drawn straight after the move: aimed from where the camera was, it had Saturn 22.6
// degrees off the middle of the view.
const toSaturn = new THREE.Vector3().sub(camera.position);
expect(camera.getWorldDirection(new THREE.Vector3()).angleTo(toSaturn)).toBeLessThan(1e-9);
});
it('opens the next body shown on its own Sun’s side, wherever the reader left the camera: Earth after Saturn in December', async () => {
await open('saturn', '2032-12-01T12:00Z');
const camera = engine.getCamera();
expect(camera.position.y).toBeLessThan(0);
// Taken north by the reader, over Saturn's unlit ring face.
camera.position.y = 0.6;
engine.tick(0.016);
expect(camera.position.y).toBeGreaterThan(0);
route.next(convertToParamMap({ id: 'earth' }));
await flushAsync();
engine.tick(0.016);
// December: Earth's Sun is south, as Saturn's was, so only the side chosen afresh moves the camera.
expect(page.sunLight.position.y).toBeLessThan(0);
expect(camera.position.y).toBeLessThan(0);
});
it('leaves the camera on its side while the Sun only grazes the equator: Mercury through two crossings', async () => {
// The Sun is south of Mercury's equator on 2026-10-20, north from about 1 November, and south
// again from about 6 December, never more than 0.034 degrees either side.
await open('mercury', '2026-10-20T00:00Z');
const camera = engine.getCamera();
expect(page.sunLight.position.y).toBeLessThan(0);
expect(camera.position.y).toBeLessThan(0);
const sunSides = new Set<number>();
for (let day = 1; day <= 60; day++) {
time.setDate(new Date(Date.parse('2026-10-20T00:00Z') + day * 86400000));
engine.tick(0.016);
sunSides.add(Math.sign(page.sunLight.position.y));
expect(camera.position.y).toBeLessThan(0);
}
expect([...sunSides].sort()).toEqual([-1, 1]);
});
it('leaves the camera where the reader orbits it while the Sun stays on one side', async () => {
await open('saturn', '2032-06-01T12:00Z');
const camera = engine.getCamera();
// Taken over the rings, to their unlit face, on purpose.
camera.position.y = 0.6;
engine.tick(0.016);
expect(camera.position.y).toBeGreaterThan(0);
});
});
@@ -1,19 +1,23 @@
import { AfterViewInit, Component, ElementRef, OnDestroy, signal, viewChild } from '@angular/core'; import { AfterViewInit, Component, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { DataLoaderService } from '../../core/data/data-loader.service'; import { DataLoaderService } from '../../core/data/data-loader.service';
import { EngineService } from '../../core/engine/engine.service'; import { EngineService } from '../../core/engine/engine.service';
import { bodyPageView } from '../../shared/rendering/body-orientation';
import { planetTexture } from '../../shared/rendering/procedural-planet-texture'; import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox'; import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SATURN_RING_TEXTURE_PATH } from '../../shared/rendering/texture-catalog'; import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, saturnRing } from '../../shared/rendering/texture-catalog';
import { BodyRecord } from '../../shared/models/body.model'; import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
import { Bookmark } from '../../shared/state/bookmarks.store';
import { NavigationStore } from '../../shared/state/navigation.store'; import { NavigationStore } from '../../shared/state/navigation.store';
import { TimeStore } from '../../shared/state/time.store';
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
import { HudDockComponent } from '../hud/hud-dock.component';
import { BodyDetailViewModel } from './body-detail.model'; import { BodyDetailViewModel } from './body-detail.model';
import { buildBodyViewModel } from './body-view-model'; import { buildBodyViewModel } from './body-view-model';
import { InfoPanelComponent } from './info-panel.component'; import { InfoPanelComponent } from './info-panel.component';
@@ -22,6 +26,18 @@ import { InfoPanelComponent } from './info-panel.component';
const GAS_GIANT_IDS = new Set(['jupiter', 'saturn', 'uranus', 'neptune']); const GAS_GIANT_IDS = new Set(['jupiter', 'saturn', 'uranus', 'neptune']);
/** The body is drawn at unit radius here, so the halo's extent is its multiple directly. */ /** The body is drawn at unit radius here, so the halo's extent is its multiple directly. */
const GLOW_SCALE = 2.6; const GLOW_SCALE = 2.6;
/** Where the page's light stands, and the Sun with it wherever the body's real one is known. */
const SUN_LIGHT_POSITION = new THREE.Vector3(4, 3, 5);
/**
* How far from the equator the Sun must stand, as the sine of its latitude, before the camera
* follows it across: 3 degrees. The side is for Saturn's rings, lit on one face only, whose Sun
* goes 26.7 degrees either side. Mercury's never leaves the equator by more than 0.034 degrees
* and crosses it 8.3 times a year, which moved the camera from one side to the other every 1.45
* seconds at a month a second, both sides lit alike; Venus's reaches 2.6 and the Moon's 1.6.
* Earth's and Saturn's pages still follow their seasons, a week and half a year after each
* equinox (2025-03-28 and 2039-08-03, measured).
*/
const SUN_SIDE_MIN_SINE = Math.sin((3 * Math.PI) / 180);
/** /**
* Separate, focused route for inspecting a single planet/moon/exoplanet: its own scene/camera * Separate, focused route for inspecting a single planet/moon/exoplanet: its own scene/camera
@@ -37,14 +53,14 @@ const GLOW_SCALE = 2.6;
@Component({ @Component({
selector: 'app-body-detail-scene', selector: 'app-body-detail-scene',
providers: [EngineService], providers: [EngineService],
imports: [ChevronIconComponent, InfoPanelComponent, RouterLink], imports: [ChevronIconComponent, HudDockComponent, InfoPanelComponent, RouterLink],
template: ` template: `
<div class="relative h-full w-full"> <div class="relative h-full w-full">
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas> <canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
@if (viewModel()) { @if (viewModel()) {
<app-info-panel [body]="viewModel()!" /> <app-info-panel [body]="viewModel()!" />
} @else if (notFound()) { } @else if (notFound()) {
<div class="hud-brackets hud-acquire hud-surface absolute top-20 right-4 w-80 max-w-[calc(100%-2rem)] p-4 font-body text-text xl:top-4"> <div class="hud-brackets hud-acquire hud-surface absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] p-4 font-body text-text">
<p class="type-eyebrow text-accent">No record</p> <p class="type-eyebrow text-accent">No record</p>
<p class="mt-2 text-sm text-muted">That id isn't in the catalog — it may have been renamed or mistyped.</p> <p class="mt-2 text-sm text-muted">That id isn't in the catalog — it may have been renamed or mistyped.</p>
<a <a
@@ -56,16 +72,35 @@ const GLOW_SCALE = 2.6;
</a> </a>
</div> </div>
} }
<!-- Search, what has been kept and the clock: there is no scene readout here, the info
panel is the reading, and the panel's own control is what keeps this body. A solar-system
body is drawn at the clock's date and turns at its rate, so both are shown and can be set
here; an exoplanet, whose day the catalogue does not carry, turns for show whatever the
clock says. -->
<app-hud-dock [date]="date()" [clock]="true" (bookmarkChosen)="goToBookmark($event)" />
</div> </div>
` `
}) })
export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
/** A kept place, revisited from this page: a star means leaving it for the map. */
goToBookmark(bookmark: Bookmark): void {
if (bookmark.kind === 'star') {
this.navigationStore.selectStar(Number(bookmark.id));
void this.router.navigate(['/']);
} else {
void this.router.navigate(['/body', String(bookmark.id)]);
}
}
private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas'); private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
private controls?: OrbitControls; private controls?: OrbitControls;
private scene?: THREE.Scene; private scene?: THREE.Scene;
private planet?: THREE.Mesh; private planet?: THREE.Mesh;
private planetMaterial?: THREE.MeshStandardMaterial; private planetMaterial?: THREE.MeshStandardMaterial;
private sunLight?: THREE.DirectionalLight;
/** The solar-system record behind the body shown, which is what can be turned by its real pole. */
private body?: BodyRecord;
private ring?: THREE.Mesh; private ring?: THREE.Mesh;
private glow?: THREE.Sprite; private glow?: THREE.Sprite;
private resizeObserver?: ResizeObserver; private resizeObserver?: ResizeObserver;
@@ -79,12 +114,18 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
readonly viewModel = signal<BodyDetailViewModel | undefined>(undefined); readonly viewModel = signal<BodyDetailViewModel | undefined>(undefined);
readonly notFound = signal(false); readonly notFound = signal(false);
/** The date the body is drawn for, as the dock's strip prints it; empty at the present. */
readonly date = signal('');
/** The side of the equator the Sun stood on at the last frame, 1 north or -1 south; 0 once a body is shown. */
private sunSide = 0;
constructor( constructor(
private readonly engine: EngineService, private readonly engine: EngineService,
private readonly dataLoader: DataLoaderService, private readonly dataLoader: DataLoaderService,
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
private readonly navigationStore: NavigationStore private readonly router: Router,
private readonly navigationStore: NavigationStore,
private readonly time: TimeStore
) {} ) {}
ngAfterViewInit(): void { ngAfterViewInit(): void {
@@ -151,8 +192,8 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
} }
// Real photography wherever it exists, and a surface derived from the body's own measured // Real photography wherever it exists, and a surface derived from the body's own measured
// properties wherever it does not — which is every exoplanet, since none has ever been // properties wherever it does not — which is every exoplanet, since none has had its
// imaged, and the handful of moons no probe returned a usable map of. // surface imaged, and the handful of moons no probe returned a usable map of.
const realTexturePath = bodyTexturePath(viewModel.id); const realTexturePath = bodyTexturePath(viewModel.id);
this.planetMaterial.map = realTexturePath ? loadCachedTexture(realTexturePath) : planetTexture(viewModel.appearance); this.planetMaterial.map = realTexturePath ? loadCachedTexture(realTexturePath) : planetTexture(viewModel.appearance);
// The texture supplies its own colour, so the base stays white rather than tinting it twice. // The texture supplies its own colour, so the base stays white rather than tinting it twice.
@@ -160,12 +201,19 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
// A fluid envelope scatters light more evenly than a solid surface does. // A fluid envelope scatters light more evenly than a solid surface does.
this.planetMaterial.roughness = GAS_GIANT_IDS.has(viewModel.id) || viewModel.appearance.palette.structure === 'banded' ? 0.55 : 0.85; this.planetMaterial.roughness = GAS_GIANT_IDS.has(viewModel.id) || viewModel.appearance.palette.structure === 'banded' ? 0.55 : 0.85;
this.planetMaterial.needsUpdate = true; this.planetMaterial.needsUpdate = true;
// Back to the page's own light and a sphere at rest; `tick` turns both where the IAU says how.
this.body = this.bodies.find((body) => body.id === viewModel.id);
this.planet?.rotation.set(0, 0, 0);
this.sunLight?.position.copy(SUN_LIGHT_POSITION);
this.sunSide = 0;
this.disposeRing(); this.disposeRing();
this.disposeGlow(); this.disposeGlow();
if (this.scene) { if (this.scene) {
if (viewModel.id === 'saturn') { if (viewModel.id === 'saturn' && this.body) {
this.ring = this.buildSaturnRing(); // Flat in the page's horizontal, which is Saturn's equator: the planet is drawn pole up, at
// unit radius. They used to reach 2.6 radii out; the outermost ring the texture draws is 2.42.
this.ring = saturnRing(this.body.radiusKm, 1);
this.scene.add(this.ring); this.scene.add(this.ring);
} }
const atmosphereColor = atmosphereColorFor(viewModel.id); const atmosphereColor = atmosphereColorFor(viewModel.id);
@@ -176,37 +224,6 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
} }
} }
/**
* Saturn's rings, built from a real ring-transparency map. `RingGeometry`'s default UVs wrap
* around the angle rather than the radius, so the per-vertex U is remapped to distance from
* center — the standard fix for sampling a radially-varying ring texture correctly.
*/
private buildSaturnRing(): THREE.Mesh {
const geometry = new THREE.RingGeometry(1.4, 2.6, 128, 1);
const position = geometry.attributes['position'];
const uv = geometry.attributes['uv'];
const vertex = new THREE.Vector3();
for (let i = 0; i < position.count; i++) {
vertex.fromBufferAttribute(position, i);
const radialFraction = THREE.MathUtils.clamp((vertex.length() - 1.4) / (2.6 - 1.4), 0, 1);
uv.setXY(i, radialFraction, 1);
}
const ringTexture = loadCachedTexture(SATURN_RING_TEXTURE_PATH);
const material = new THREE.MeshBasicMaterial({
map: ringTexture,
alphaMap: ringTexture,
transparent: true,
opacity: 0.85,
side: THREE.DoubleSide,
depthWrite: false
});
const ring = new THREE.Mesh(geometry, material);
ring.rotation.x = Math.PI / 2 - THREE.MathUtils.degToRad(17);
return ring;
}
private disposeRing(): void { private disposeRing(): void {
if (!this.ring) { if (!this.ring) {
return; return;
@@ -252,9 +269,9 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
this.controls.maxDistance = 12; this.controls.maxDistance = 12;
scene.add(new THREE.AmbientLight(0xffffff, 0.35)); scene.add(new THREE.AmbientLight(0xffffff, 0.35));
const sunLight = new THREE.DirectionalLight(0xfff4e0, 1.6); this.sunLight = new THREE.DirectionalLight(0xfff4e0, 1.6);
sunLight.position.set(4, 3, 5); this.sunLight.position.copy(SUN_LIGHT_POSITION);
scene.add(sunLight); scene.add(this.sunLight);
const geometry = new THREE.SphereGeometry(1, 64, 48); const geometry = new THREE.SphereGeometry(1, 64, 48);
const viewModel = this.viewModel(); const viewModel = this.viewModel();
@@ -273,11 +290,46 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
this.engine.start(); this.engine.start();
} }
/**
* A body the IAU gives rotational elements for is turned as it is at the map's date, under its
* real Sun, at the rate the map's clock runs (see `bodyPageView`). Eris, Haumea, Makemake and
* Nereid, whose day is measured but whose pole is not, turn pole up at that day on the same
* clock, as the system view turns them; Hyperion, which tumbles, is left still, as it is there.
* An exoplanet turns slowly for show, as the page always turned it.
*/
private tick(deltaSeconds: number): void { private tick(deltaSeconds: number): void {
this.controls?.update(); this.controls?.update();
if (this.planet) { this.date.set(this.time.atNow() ? '' : this.time.date().toISOString().slice(0, 10));
if (!this.planet || !this.sunLight) {
return;
}
const sunAzimuth = Math.atan2(SUN_LIGHT_POSITION.x, SUN_LIGHT_POSITION.z);
if (this.body && bodyPageView(this.body, this.bodies, this.time.julianDate(), sunAzimuth, this.planet.quaternion, this.sunLight.position)) {
this.sunLight.position.multiplyScalar(SUN_LIGHT_POSITION.length());
} else if (this.body?.rotationPeriodHours !== undefined) {
// Counted from the orbit's epoch, as `spinFor` counts: where the meridian starts is unknown.
const turns = ((this.time.julianDate() - this.body.orbit.epochJd) * 24) / this.body.rotationPeriodHours;
this.planet.rotation.set(0, (turns % 1) * 2 * Math.PI, 0);
} else if (!this.body) {
this.planet.rotation.y += deltaSeconds * 0.08; this.planet.rotation.y += deltaSeconds * 0.08;
} }
const sunLatitudeSine = this.sunLight.position.y / this.sunLight.position.length();
const sunSide = this.sunSide !== 0 && Math.abs(sunLatitudeSine) < SUN_SIDE_MIN_SINE ? this.sunSide : sunLatitudeSine < 0 ? -1 : 1;
if (sunSide !== this.sunSide) {
// Above or below the equator, whichever side the Sun is on, when a body is shown and again
// whenever the Sun is well across it (SUN_SIDE_MIN_SINE), as the clock runs or is set: held
// above it, the page opened Saturn on the unlit face of its rings from 2025 until 2039, while
// the Sun is south of them — the face Earth does not see either — and the Clock set to 2045
// left it on the other one. Between crossings the camera is the reader's to orbit where they like.
this.sunSide = sunSide;
const camera = this.engine.getCamera();
camera.position.y = Math.abs(camera.position.y) * sunSide;
// Aimed again before this frame is drawn: the controls aimed it from where it was, and the
// frame drawn from here otherwise had the body 22.6 degrees off the middle of the view.
if (this.controls) {
camera.lookAt(this.controls.target);
}
}
} }
private observeResize(canvas: HTMLCanvasElement): void { private observeResize(canvas: HTMLCanvasElement): void {
@@ -20,6 +20,8 @@ export interface BodyDetailViewModel {
*/ */
hostStarId?: number; hostStarId?: number;
radiusKm?: number; radiusKm?: number;
/** A triaxial body's semi-axes, where `radiusKm` is the mean of them; see `BodyRecord.semiAxesKm`. */
semiAxesKm?: readonly [number, number, number];
massEarth?: number; massEarth?: number;
discoveryYear?: number; discoveryYear?: number;
orbit: Partial<OrbitalElements>; orbit: Partial<OrbitalElements>;
@@ -32,10 +34,11 @@ export interface BodyDetailViewModel {
appearance: PlanetAppearance; appearance: PlanetAppearance;
/** True when a real photograph is being shown rather than the derived surface. */ /** True when a real photograph is being shown rather than the derived surface. */
hasPhotography: boolean; hasPhotography: boolean;
/** An exoplanet photographed by direct imaging, as a point of light; see `ExoplanetRecord.imaged`. */
imaged?: boolean;
/** /**
* Sidereal orbital period. Measured where the archive published one; otherwise derived from the * Sidereal orbital period. For a solar-system body, 360 degrees over JPL's published mean
* semi-major axis for heliocentric orbits, where the central mass is known exactly. Undefined * motion; for an exoplanet, the archive's period where it published one, and undefined where not.
* when neither applies — see `heliocentricPeriodDays`.
*/ */
orbitalPeriodDays?: number; orbitalPeriodDays?: number;
/** /**
@@ -44,4 +47,6 @@ export interface BodyDetailViewModel {
* derived surface as a photograph. * derived surface as a photograph.
*/ */
orbitalPeriodSource?: 'measured' | 'derived'; orbitalPeriodSource?: 'measured' | 'derived';
/** Where the orbit comes from and the span it holds over; see `BodyRecord.orbitSource`. */
orbitSource?: string;
} }
+26 -5
View File
@@ -31,7 +31,11 @@ export interface BodyReadouts {
export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts { export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts {
const measured: Readout[] = []; const measured: Readout[] = [];
if (body.radiusKm !== undefined) { if (body.radiusKm !== undefined) {
measured.push({ label: 'Radius', value: formatRadiusKm(body.radiusKm) }); // A triaxial body is drawn as the sphere of its volume; a radius alone would hide its shape.
measured.push({ label: body.semiAxesKm ? 'Mean radius' : 'Radius', value: formatRadiusKm(body.radiusKm) });
}
if (body.semiAxesKm) {
measured.push({ label: 'Semi-axes', value: `${body.semiAxesKm.map((axis) => axis.toLocaleString('en-GB')).join(' × ')} km` });
} }
if (body.massEarth !== undefined) { if (body.massEarth !== undefined) {
measured.push({ label: 'Mass', value: formatMassEarth(body.massEarth) }); measured.push({ label: 'Mass', value: formatMassEarth(body.massEarth) });
@@ -43,7 +47,9 @@ export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts {
measured.push({ label: 'Eccentricity', value: body.orbit.eccentricity.toFixed(3) }); measured.push({ label: 'Eccentricity', value: body.orbit.eccentricity.toFixed(3) });
} }
if (body.orbit.inclinationDeg !== undefined) { if (body.orbit.inclinationDeg !== undefined) {
measured.push({ label: 'Inclination', value: `${body.orbit.inclinationDeg.toFixed(2)}°` }); // Its size: Standish fits Earth's as -0.00054 degrees, which is the same orbit as +0.00054 with
// the node half a turn round, and printed as it stands read "-0.00°".
measured.push({ label: 'Inclination', value: `${Math.abs(body.orbit.inclinationDeg).toFixed(2)}°` });
} }
// The period sits under whichever heading its provenance calls for. Same number, same field — // The period sits under whichever heading its provenance calls for. Same number, same field —
// a published period is an observation and a computed one is not. // a published period is an observation and a computed one is not.
@@ -65,18 +71,33 @@ export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts {
derived.push({ label: 'Bulk density', value: formatDensity(body.appearance.bulkDensityGramsPerCm3) }); derived.push({ label: 'Bulk density', value: formatDensity(body.appearance.bulkDensityGramsPerCm3) });
} }
return { kindLabel: KIND_LABELS[body.kind], measured, derived, provenance: provenanceFor(body) }; const provenance = body.orbitSource ? `${provenanceFor(body)} Orbit: ${body.orbitSource}.` : provenanceFor(body);
return { kindLabel: KIND_LABELS[body.kind], measured, derived, provenance };
} }
/** /**
* The derived surface is a reasoned illustration, and a panel of real measurements sitting next * The derived surface is a reasoned illustration, and a panel of real measurements sitting next
* to it is exactly the context in which it could be mistaken for another one. * to it is exactly the context in which it could be mistaken for another one.
*
* A moon or dwarf planet drawn this way has been imaged — Voyager 2 photographed Uranus's five
* large moons, Proteus and Nereid, Cassini Hyperion, and Hubble sees Eris, Haumea and Makemake as
* points — but has no global map this app can use. So have the hundred or so exoplanets the
* archive flags as imaged, HR 8799's four among them, though only as points of light beside their
* star — and one of them has a map, not used here: Luhman 16 b, a brown dwarf, mapped by Doppler
* imaging (Crossfield et al. 2014, Nature 505, 654). Only the other exoplanets, known from what they
* do to starlight, have no image at all.
*/ */
function provenanceFor(body: BodyDetailViewModel): string { function provenanceFor(body: BodyDetailViewModel): string {
if (body.hasPhotography) { if (body.hasPhotography) {
return 'Surface: NASA/ESA/USGS photography.'; return 'Surface: NASA/ESA/USGS photography.';
} }
const why =
body.kind !== 'exoplanet'
? 'no global map of this world is used here'
: body.imaged
? 'it has been imaged only as a point of light beside its star, and no map of it is used here'
: 'no image of this world exists';
return body.appearance.equilibriumTemperatureK === null return body.appearance.equilibriumTemperatureK === null
? 'Surface illustrated from this body’s measured size and mass. Its host star is not in the catalogue, so no temperature could be derived. Not an observation — no image of this world exists.' ? `Surface illustrated from this body’s measured size and mass. Its host star is not in the catalogue, so no temperature could be derived. Not an observation — ${why}.`
: 'Surface illustrated from the measurements above — size, density and the temperature derived from its star’s output and its orbit. Not an observation — no image of this world exists.'; : `Surface illustrated from the measurements above — size, density and the temperature derived from its star’s output and its orbit. Not an observation — ${why}.`;
} }
@@ -1,9 +1,13 @@
/// <reference types="node" />
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model'; import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model'; import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model';
import { buildBodyViewModel, heliocentricPeriodDays } from './body-view-model'; import { bodyReadouts } from './body-readouts';
import { buildBodyViewModel } from './body-view-model';
const orbit = (overrides: Partial<OrbitalElements> = {}): OrbitalElements => ({ const orbit = (overrides: Partial<OrbitalElements> = {}): OrbitalElements => ({
semiMajorAxisAu: 1, semiMajorAxisAu: 1,
@@ -34,6 +38,9 @@ const earth: BodyRecord = {
kind: 'planet', kind: 'planet',
radiusKm: 6371, radiusKm: 6371,
orbit: orbit(), orbit: orbit(),
// Standish's mean longitude rate, 35 999.373 degrees a century.
rates: { meanMotionDegPerDay: 35999.37306329 / 36525, longitudeOfAscendingNodeDegPerDay: 0, argumentOfPeriapsisDegPerDay: 0 },
orbitSource: 'JPL approximate mean elements (Standish), fit for 3000 BC to AD 3000',
}; };
const luna: BodyRecord = { const luna: BodyRecord = {
id: 'luna', id: 'luna',
@@ -43,45 +50,74 @@ const luna: BodyRecord = {
radiusKm: 1737, radiusKm: 1737,
parentBodyId: 'earth', parentBodyId: 'earth',
orbit: orbit({ semiMajorAxisAu: 0.00257 }), orbit: orbit({ semiMajorAxisAu: 0.00257 }),
// JPL SSD's sidereal mean motion for the Moon.
rates: { meanMotionDegPerDay: 13.176358, longitudeOfAscendingNodeDegPerDay: -0.05299, argumentOfPeriapsisDegPerDay: 0.16435 },
orbitSource: 'JPL SSD satellite mean elements, epoch 2000 Jan 1',
}; };
describe('heliocentricPeriodDays', () => {
it('recovers a known period from the semi-major axis alone', () => {
// P² = a³ in these units, so Earth must come back a year.
expect(heliocentricPeriodDays(earth)).toBeCloseTo(365.25, 1);
});
it('scales as the three-halves power', () => {
const jupiter: BodyRecord = {
...earth,
id: 'jupiter',
name: 'Jupiter',
orbit: orbit({ semiMajorAxisAu: 5.2044 }),
};
// Jupiter's real sidereal period is 4332.6 days.
expect(heliocentricPeriodDays(jupiter)).toBeCloseTo(4335, -1);
});
it('refuses to compute a period for a moon', () => {
// A moon's elements are relative to its planet, whose mass is not in the catalogue — the
// same arithmetic would be wrong by the ratio of that planet's mass to the Sun's.
expect(heliocentricPeriodDays(luna)).toBeUndefined();
});
});
describe('buildBodyViewModel', () => { describe('buildBodyViewModel', () => {
const catalogues = { bodies: [earth, luna], exoplanets: [] as ExoplanetRecord[], stars: [sun] }; const catalogues = { bodies: [earth, luna], exoplanets: [] as ExoplanetRecord[], stars: [sun] };
it('marks a period computed from the semi-major axis as derived', () => { it('gives a planet the sidereal year its published mean motion goes round in', () => {
const model = buildBodyViewModel('earth', catalogues); const model = buildBodyViewModel('earth', catalogues);
expect(model?.orbitalPeriodSource).toBe('derived'); expect(model?.orbitalPeriodSource).toBe('measured');
expect(model?.orbitalPeriodDays).toBeCloseTo(365.25, 1); expect(model?.orbitalPeriodDays).toBeCloseTo(365.2564, 4);
}); });
it('leaves a moon without a period rather than inventing one', () => { it('gives a moon its period too, from the same mean motion that carries it round', () => {
// The card used to refuse, while the scene turned the Moon round the Earth all the same.
const model = buildBodyViewModel('luna', catalogues); const model = buildBodyViewModel('luna', catalogues);
expect(model?.orbitalPeriodDays).toBeUndefined(); expect(model?.orbitalPeriodSource).toBe('measured');
expect(model?.orbitalPeriodSource).toBeUndefined(); expect(model?.orbitalPeriodDays).toBeCloseTo(27.32166, 5);
});
it('prints the size of an inclination fitted below zero, as the same orbit with its node turned half round', () => {
const tilted: BodyRecord = { ...earth, orbit: orbit({ inclinationDeg: -0.00054346 }) };
const model = buildBodyViewModel('earth', { ...catalogues, bodies: [tilted] })!;
expect(bodyReadouts(model).measured.find((row) => row.label === 'Inclination')?.value).toBe('0.00°');
});
it('prints the eccentricity measured for a moon whose orbit keeps an older one', () => {
const hyperion: BodyRecord = { ...luna, id: 'hyperion', orbit: orbit({ eccentricity: 0.0232 }), measuredEccentricity: 0.105 };
const model = buildBodyViewModel('hyperion', { ...catalogues, bodies: [earth, hyperion] })!;
expect(bodyReadouts(model).measured.find((row) => row.label === 'Eccentricity')?.value).toBe('0.105');
});
it('gives a triaxial body its semi-axes beside its mean radius, not a radius alone', () => {
// As shipped: Haumea's shape (Ortiz et al. 2017) travels from the ETL's spec to its card.
const shipped: BodyRecord[] = JSON.parse(readFileSync(`${process.cwd()}/src/assets/data/bodies.json`, 'utf8'));
const haumea = shipped.find((body) => body.id === 'haumea')!;
const measured = bodyReadouts(buildBodyViewModel('haumea', { ...catalogues, bodies: [earth, haumea] })!).measured;
expect(measured.find((row) => row.label === 'Mean radius')?.value).toBe('798 km');
expect(measured.find((row) => row.label === 'Semi-axes')?.value).toBe('1,161 × 852 × 513 km');
expect(measured.find((row) => row.label === 'Radius')).toBeUndefined();
// Every other body keeps its one radius.
expect(bodyReadouts(buildBodyViewModel('earth', catalogues)!).measured.find((row) => row.label === 'Radius')?.value).toBe('6,371 km');
});
it('says where the orbit comes from, in the card’s provenance', () => {
expect(bodyReadouts(buildBodyViewModel('luna', catalogues)!).provenance).toContain('Orbit: JPL SSD satellite mean elements, epoch 2000 Jan 1.');
});
it('says a moon without a map is illustrated, without saying it was never imaged', () => {
// luna has no map under that id. Voyager and Cassini photographed every moon drawn this way.
const provenance = bodyReadouts(buildBodyViewModel('luna', catalogues)!).provenance;
expect(provenance).toContain('Not an observation — no global map of this world is used here.');
expect(provenance).not.toContain('no image of this world exists');
});
it('says an exoplanet the archive does not flag as imaged has no image', () => {
const exoplanet: ExoplanetRecord = { id: 'x', hostStarId: SUN_STAR_ID, hostStarName: 'Sol', name: 'X b', orbit: { semiMajorAxisAu: 0.05 } };
const model = buildBodyViewModel('x', { bodies: [], exoplanets: [exoplanet], stars: [sun] })!;
expect(bodyReadouts(model).provenance).toContain('Not an observation — no image of this world exists.');
});
it('says a directly imaged exoplanet was seen as a point of light, not that no image of it exists', () => {
// HR 8799 b: photographed beside its star at Gemini and Keck (Marois et al. 2008).
const exoplanet: ExoplanetRecord = { id: 'HR 8799 b', hostStarId: SUN_STAR_ID, hostStarName: 'HR 8799', name: 'HR 8799 b', imaged: true, orbit: { semiMajorAxisAu: 68 } };
const provenance = bodyReadouts(buildBodyViewModel('HR 8799 b', { bodies: [], exoplanets: [exoplanet], stars: [sun] })!).provenance;
expect(provenance).toContain('Not an observation — it has been imaged only as a point of light beside its star, and no map of it is used here.');
expect(provenance).not.toContain('no image of this world exists');
}); });
it('marks a published exoplanet period as measured, not derived', () => { it('marks a published exoplanet period as measured, not derived', () => {
+10 -20
View File
@@ -4,7 +4,7 @@ import { luminositySolar } from '../../shared/astro/stellar';
import { bodyTexturePath } from '../../shared/rendering/texture-catalog'; import { bodyTexturePath } from '../../shared/rendering/texture-catalog';
import { BodyRecord } from '../../shared/models/body.model'; import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
import { BodyDetailViewModel } from './body-detail.model'; import { BodyDetailViewModel } from './body-detail.model';
/** Everything the view model is assembled from — the three catalogues, already loaded. */ /** Everything the view model is assembled from — the three catalogues, already loaded. */
@@ -41,7 +41,10 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body
const body = catalogues.bodies.find((candidate) => candidate.id === id); const body = catalogues.bodies.find((candidate) => candidate.id === id);
if (body) { if (body) {
const hostStar = catalogues.stars.find((star) => star.id === body.systemStarId); const hostStar = catalogues.stars.find((star) => star.id === body.systemStarId);
const periodDays = heliocentricPeriodDays(body); // The period the map draws, moons included: JPL's own mean motion, which is also what
// carries the body round the scene. Europa's card had no period at all while the scene
// turned it round Jupiter in 3.55 days.
const periodDays = 360 / body.rates.meanMotionDegPerDay;
return { return {
id: body.id, id: body.id,
name: body.name, name: body.name,
@@ -49,11 +52,13 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body
hostStarName: hostStar?.name ?? 'Unknown star', hostStarName: hostStar?.name ?? 'Unknown star',
hostStarId: body.systemStarId, hostStarId: body.systemStarId,
radiusKm: body.radiusKm, radiusKm: body.radiusKm,
orbit: body.orbit, semiAxesKm: body.semiAxesKm,
orbit: body.measuredEccentricity === undefined ? body.orbit : { ...body.orbit, eccentricity: body.measuredEccentricity },
appearance: appearanceForBody(body, catalogues.bodies, luminosityOf(hostStar)), appearance: appearanceForBody(body, catalogues.bodies, luminosityOf(hostStar)),
hasPhotography: bodyTexturePath(body.id) !== undefined, hasPhotography: bodyTexturePath(body.id) !== undefined,
orbitalPeriodDays: periodDays, orbitalPeriodDays: periodDays,
orbitalPeriodSource: periodDays === undefined ? undefined : 'derived', orbitalPeriodSource: 'measured',
orbitSource: body.orbitSource,
}; };
} }
@@ -74,6 +79,7 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body
orbit: exoplanet.orbit, orbit: exoplanet.orbit,
appearance: appearanceForExoplanet(exoplanet, luminosityOf(hostStar)), appearance: appearanceForExoplanet(exoplanet, luminosityOf(hostStar)),
hasPhotography: bodyTexturePath(exoplanet.id) !== undefined, hasPhotography: bodyTexturePath(exoplanet.id) !== undefined,
imaged: exoplanet.imaged,
// `periodDays` is populated for none of the shipped records, and deriving one would need the // `periodDays` is populated for none of the shipped records, and deriving one would need the
// host star's mass, which is equally absent. Left undefined rather than assuming a solar-mass // host star's mass, which is equally absent. Left undefined rather than assuming a solar-mass
// host, which would silently mis-state the period of every planet around an M dwarf. // host, which would silently mis-state the period of every planet around an M dwarf.
@@ -81,19 +87,3 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body
orbitalPeriodSource: exoplanet.periodDays === undefined ? undefined : 'measured', orbitalPeriodSource: exoplanet.periodDays === undefined ? undefined : 'measured',
}; };
} }
/**
* Kepler's third law for a body orbiting the Sun: P² = a³ with P in years and a in AU, which
* holds exactly in these units because the Sun's mass is the unit of mass.
*
* Only for heliocentric orbits. A moon's elements are relative to its parent planet, whose mass
* the catalogue does not carry, so the same arithmetic there would be wrong by the ratio of the
* planet's mass to the Sun's — a factor of a thousand for Jupiter.
*/
export function heliocentricPeriodDays(body: BodyRecord): number | undefined {
if (body.parentBodyId !== undefined || body.systemStarId !== SUN_STAR_ID) {
return undefined;
}
const a = body.orbit.semiMajorAxisAu;
return a > 0 ? Math.pow(a, 1.5) * 365.25 : undefined;
}
@@ -1,6 +1,9 @@
import { Component, computed, input } from '@angular/core'; import { Component, computed, effect, inject, input, signal } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Article, ArticleService } from '../../core/data/article.service';
import { BookmarksStore } from '../../shared/state/bookmarks.store';
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
import { bodyReadouts } from './body-readouts'; import { bodyReadouts } from './body-readouts';
import { BodyDetailViewModel } from './body-detail.model'; import { BodyDetailViewModel } from './body-detail.model';
@@ -17,12 +20,10 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
*/ */
@Component({ @Component({
selector: 'app-info-panel', selector: 'app-info-panel',
imports: [ChevronIconComponent, ReadoutSectionsComponent], imports: [BookmarkIconComponent, ChevronIconComponent, ReadoutSectionsComponent],
template: ` template: `
<!-- Sits below the search field until xl, beside it from there. The search field is 26rem <!-- Top-right, clear of the dock along the bottom; nothing else shares the top edge here. -->
wide and centred, so this right-anchored 20rem panel only clears it once the viewport <div class="hud-brackets hud-acquire hud-surface absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] font-body text-text">
passes ~1088px — at sm they still overlap and the search would cover the back button. -->
<div class="hud-brackets hud-acquire hud-surface absolute top-20 right-4 w-80 max-w-[calc(100%-2rem)] font-body text-text xl:top-4">
<button <button
type="button" type="button"
(click)="goBack()" (click)="goBack()"
@@ -32,21 +33,105 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
System System
</button> </button>
<header class="px-4 pt-4 pb-3"> <header class="flex items-start gap-2 px-4 pt-4 pb-3">
<div class="min-w-0 flex-1">
<h1 class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</h1> <h1 class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</h1>
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p> <p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
</div>
<button
type="button"
[attr.aria-label]="(bookmarks.has('body', body().id) ? 'Forget ' : 'Keep ') + body().name"
[attr.aria-pressed]="bookmarks.has('body', body().id)"
(click)="bookmarks.toggle({ kind: 'body', id: body().id, name: body().name })"
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
[class]="bookmarks.has('body', body().id) ? 'text-accent' : 'text-muted hover:text-accent'"
>
<app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('body', body().id)" />
</button>
</header> </header>
<app-readout-sections [readouts]="readouts()" /> <app-readout-sections [readouts]="readouts()" />
<!-- Prose from elsewhere, and only when asked for. Everything above this line is a
measurement or something derived from one; this is a person's paragraph on another
site, so it says whose and links back to it. -->
@if (article(); as found) {
<!-- Capped and scrollable: a Wikipedia lead can run to a dozen lines, and this panel is
anchored to the top of a viewport that may be a good deal shorter than the prose. -->
<div class="max-h-56 overflow-y-auto border-t border-border/40 px-4 py-3">
<p class="text-[11px] leading-relaxed text-muted">{{ found.extract }}</p>
<a
[href]="found.url"
target="_blank"
rel="noopener"
class="type-label mt-2 inline-block text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent"
>Wikipedia · {{ found.language }}</a
>
</div>
} @else if (aboutState() !== 'idle') {
<p class="border-t border-border/40 px-4 py-3 text-[11px] leading-relaxed text-muted">
@switch (aboutState()) {
@case ('loading') {
Asking Wikipedia…
}
@case ('none') {
Wikipedia has no article on {{ body().name }}.
}
@case ('unavailable') {
Wikipedia could not be reached.
<button type="button" (click)="loadArticle()" class="text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent">Try again</button>
}
}
</p>
} @else {
<button
type="button"
(click)="loadArticle()"
class="type-label w-full border-t border-border/40 px-4 py-2 text-left text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:bg-accent/12 focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
>
About
</button>
}
</div> </div>
` `
}) })
export class InfoPanelComponent { export class InfoPanelComponent {
readonly body = input.required<BodyDetailViewModel>(); readonly body = input.required<BodyDetailViewModel>();
readonly readouts = computed(() => bodyReadouts(this.body())); readonly bookmarks = inject(BookmarksStore);
constructor(private readonly router: Router) {} private readonly articles = inject(ArticleService);
readonly article = signal<Article | null>(null);
readonly aboutState = signal<'idle' | 'loading' | 'none' | 'unavailable'>('idle');
constructor(private readonly router: Router) {
// The panel is reused as the route's parameter changes, so what was asked about one body
// must not still be showing under the next one's name.
effect(() => {
this.body();
this.article.set(null);
this.aboutState.set('idle');
});
}
/**
* Fetches the article, on the press and not before. The kind goes with the name because
* Wikipedia disambiguates by it — "Titan" alone is a list of everything called Titan.
*/
async loadArticle(): Promise<void> {
this.aboutState.set('loading');
const result = await this.articles.lookup(this.body().name, this.readouts().kindLabel.toLowerCase());
if (result.status === 'found') {
this.article.set(result.article);
this.aboutState.set('idle');
return;
}
this.article.set(null);
this.aboutState.set(result.status);
}
readonly readouts = computed(() => bodyReadouts(this.body()));
goBack(): void { goBack(): void {
void this.router.navigate(['/']); void this.router.navigate(['/']);
@@ -0,0 +1,19 @@
/**
* How a size in pixels becomes a size the scene can draw.
*
* Sprites in this view are sized angularly rather than in world units, so a star holds the same
* share of the screen however far away it is and whatever the window is doing. Pixels are what
* the figures are chosen in, though — "a star is between one and a half and six pixels across"
* is a statement someone can check by looking — so the two are related through a reference
* viewport and field of view, and the pixel figures are exact only at that height.
*
* Shared rather than restated per renderer: the star field and the rings drawn over it have to
* agree, or a ring sits a little wide of the star it belongs to at some window sizes and not at
* others.
*/
export const REFERENCE_VIEWPORT_HEIGHT_PX = 900;
export const REFERENCE_FOV_DEGREES = 55;
/** Multiply a size in reference pixels by this to get the angular size the material wants. */
export const PIXELS_TO_ANGULAR_SIZE = (2 * Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 180 / 2)) / REFERENCE_VIEWPORT_HEIGHT_PX;
@@ -1,4 +1,6 @@
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
import { SceneCamera } from '../../core/engine/engine.service';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
export interface CameraPose { export interface CameraPose {
@@ -27,7 +29,7 @@ export class CameraRigController {
}; };
constructor( constructor(
private readonly camera: THREE.PerspectiveCamera, private readonly camera: SceneCamera,
private readonly controls: OrbitControls private readonly controls: OrbitControls
) {} ) {}
@@ -1,16 +1,27 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, MockInstance, vi } from 'vitest';
import { DataLoaderService, StarField } from '../../core/data/data-loader.service'; import { DataLoaderService, StarField } from '../../core/data/data-loader.service';
import { EngineService, EngineTickCallback } from '../../core/engine/engine.service'; import { EngineService, EngineTickCallback } from '../../core/engine/engine.service';
import { BodyRecord } from '../../shared/models/body.model'; import { BodyRecord } from '../../shared/models/body.model';
import { GM_SUN_AU3_PER_DAY2 } from '../../shared/astro/constants';
import { keplerRates } from '../../shared/astro/kepler';
import { DeepSkyRecord } from '../../shared/models/deepsky.model'; import { DeepSkyRecord } from '../../shared/models/deepsky.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
import { NavigationStore } from '../../shared/state/navigation.store'; import { NavigationStore } from '../../shared/state/navigation.store';
import { TimeStore } from '../../shared/state/time.store';
import { LinkBudget } from '../../shared/astro/jump-links';
import { HudDisplay } from '../hud/hud-dock.component';
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component'; import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
import { galacticNormal } from './grid-plane';
import { JumpLinkRenderer } from './jump-link-renderer';
import { StarFieldRenderer } from './star-field-renderer';
import { systemFramingDistanceAu } from './system-framing';
import { SystemOrbitsRenderer } from './system-orbits-renderer';
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
// jsdom does not implement ResizeObserver; the component only uses it to react to real // jsdom does not implement ResizeObserver; the component only uses it to react to real
// layout changes, which never happen in this headless test. // layout changes, which never happen in this headless test.
@@ -22,7 +33,8 @@ import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
const SUN: StarRecord = { id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 }; const SUN: StarRecord = { id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 };
const ALPHA_CENTAURI: StarRecord = { id: 1, name: 'Alpha Centauri', x: 1.34, y: 0, z: 0, magnitude: 4.4, spectralType: 'G2V', colorIndex: 0.7 }; const ALPHA_CENTAURI: StarRecord = { id: 1, name: 'Alpha Centauri', x: 1.34, y: 0, z: 0, magnitude: 4.4, spectralType: 'G2V', colorIndex: 0.7 };
const PROXIMA: StarRecord = { id: 2, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 }; // Its id deliberately differs from its place in STARS, so a lookup by id cannot pass for one by index.
const PROXIMA: StarRecord = { id: 42, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 };
const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA]; const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA];
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z])); const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
@@ -56,7 +68,8 @@ const EARTH: BodyRecord = {
argumentOfPeriapsisDeg: 0, argumentOfPeriapsisDeg: 0,
meanAnomalyAtEpochDeg: 0, meanAnomalyAtEpochDeg: 0,
epochJd: 2451545.0 epochJd: 2451545.0
} },
rates: keplerRates(1, GM_SUN_AU3_PER_DAY2), orbitSource: 'test'
}; };
/** Minimal stand-in for `EngineService` that skips real WebGPU/WebGL initialization entirely, /** Minimal stand-in for `EngineService` that skips real WebGPU/WebGL initialization entirely,
@@ -64,7 +77,9 @@ const EARTH: BodyRecord = {
class FakeEngineService { class FakeEngineService {
private readonly scene = new THREE.Scene(); private readonly scene = new THREE.Scene();
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000); private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
private readonly orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
private readonly tickCallbacks = new Set<EngineTickCallback>(); private readonly tickCallbacks = new Set<EngineTickCallback>();
projection: 'perspective' | 'orthographic' = 'perspective';
get isInitialized(): boolean { get isInitialized(): boolean {
return true; return true;
@@ -78,10 +93,41 @@ class FakeEngineService {
return this.scene; return this.scene;
} }
getCamera(): THREE.PerspectiveCamera { getCamera(): THREE.PerspectiveCamera | THREE.OrthographicCamera {
return this.projection === 'orthographic' ? this.orthographic : this.camera;
}
getPerspectiveCamera(): THREE.PerspectiveCamera {
return this.camera; return this.camera;
} }
get currentProjection(): 'perspective' | 'orthographic' {
return this.projection;
}
setProjection(projection: 'perspective' | 'orthographic', distanceToTarget: number): void {
this.projection = projection;
this.orthographic.zoom = 1;
this.orthographic.position.copy(this.camera.position);
this.orthographic.quaternion.copy(this.camera.quaternion);
this.frameOrthographic(distanceToTarget);
}
frameOrthographic(distanceToTarget: number): void {
const halfHeight = Math.max(distanceToTarget, 1e-6) * Math.tan((this.camera.fov * Math.PI) / 360);
this.orthographic.top = halfHeight;
this.orthographic.bottom = -halfHeight;
this.orthographic.left = -halfHeight * this.camera.aspect;
this.orthographic.right = halfHeight * this.camera.aspect;
this.orthographic.updateProjectionMatrix();
}
visibleHalfHeight(distanceToTarget: number): number {
return this.projection === 'orthographic'
? (this.orthographic.top - this.orthographic.bottom) / (2 * this.orthographic.zoom)
: distanceToTarget * Math.tan((this.camera.fov * Math.PI) / 360);
}
onTick(callback: EngineTickCallback): () => void { onTick(callback: EngineTickCallback): () => void {
this.tickCallbacks.add(callback); this.tickCallbacks.add(callback);
return () => this.tickCallbacks.delete(callback); return () => this.tickCallbacks.delete(callback);
@@ -95,6 +141,13 @@ class FakeEngineService {
resize(): void {} resize(): void {}
/** The canvas's device pixels per CSS pixel, as the renderer was told. */
pixelRatio = 1;
getRenderer(): { getPixelRatio(): number } {
return { getPixelRatio: () => this.pixelRatio };
}
/** Test helper: simulates one rendered frame by invoking every registered tick callback. */ /** Test helper: simulates one rendered frame by invoking every registered tick callback. */
tick(deltaSeconds: number): void { tick(deltaSeconds: number): void {
for (const callback of this.tickCallbacks) { for (const callback of this.tickCallbacks) {
@@ -171,6 +224,602 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
expect(navigationStore.viewLevel()).toBe('galaxy'); expect(navigationStore.viewLevel()).toBe('galaxy');
}); });
it('clears a selection the catalogue no longer holds instead of chasing it', async () => {
// A bookmark saved against a Gaia row id that the next refresh renumbered. Before the guard,
// entering the missing system completed at once, completion re-read the same id, and the
// two recursed until the stack overflowed.
navigationStore.selectStar(987654321);
await flushAsync();
expect(navigationStore.selectedStarId()).toBeNull();
expect(navigationStore.viewLevel()).toBe('galaxy');
});
it('chooses the drawn stars again once the view centre has moved, and not for a small drift', async () => {
const component = fixture.componentInstance as unknown as { controls: { target: THREE.Vector3 } };
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
// The first pass always chooses; what is under test is the move after it.
await advanceFrames(engine, 0.3);
refocus.mockClear();
component.controls.target.set(40, 0, 0);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(1);
expect(refocus.mock.calls[0][0].centre).toMatchObject({ x: 40, y: 0, z: 0 });
component.controls.target.set(42, 0, 0);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(1);
refocus.mockRestore();
});
describe('the drawn stars, chosen for what the camera shows', () => {
type ViewScene = { controls: { target: THREE.Vector3; update(): void }; display: { update(change: (display: HudDisplay) => HudDisplay): void } };
let refocus: MockInstance<StarFieldRenderer['refocus']>;
beforeEach(() => {
refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
});
afterEach(() => refocus.mockRestore());
/** Swings the camera about the view's centre, around the scene's vertical, by `degrees`. */
function orbit(component: ViewScene, degrees: number): void {
const camera = engine.getCamera();
const target = component.controls.target;
camera.position.sub(target).applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(degrees)).add(target);
component.controls.update();
}
it('chooses them for the opening view on the first pass, planet hosts included', async () => {
await advanceFrames(engine, 0.6);
expect(refocus).toHaveBeenCalledTimes(1);
const [focus] = refocus.mock.calls[0];
expect(focus.view).toBeDefined();
// The Sun has Earth, so it is a host; the others have nothing catalogued.
expect(Array.from(focus.hosts ?? [])).toEqual([1, 0, 0]);
});
it('chooses again once the camera has turned half the margin, and not for less', async () => {
const component = fixture.componentInstance as unknown as ViewScene;
await advanceFrames(engine, 0.3);
orbit(component, 1);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(1);
orbit(component, 3);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(2);
});
it('chooses again once a pan has moved the view further than a fifth of the neighbourhood, and not for less', async () => {
const component = fixture.componentInstance as unknown as ViewScene;
const camera = engine.getCamera();
// Camera and centre together, so the camera neither turns nor zooms.
const pan = (pc: number) => {
component.controls.target.x += pc;
camera.position.x += pc;
component.controls.update();
};
await advanceFrames(engine, 0.3);
pan(3);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(1);
pan(3);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(2);
});
it('chooses again once a zoom has changed the frame by half the margin, and not for less', async () => {
const component = fixture.componentInstance as unknown as ViewScene;
const camera = engine.getCamera();
const dolly = (factor: number) => camera.position.sub(component.controls.target).multiplyScalar(factor).add(component.controls.target);
await advanceFrames(engine, 0.3);
dolly(0.95);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(1);
dolly(0.8);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(2);
});
it('chooses again for the plan view, where a small turn moves deep stars furthest', async () => {
const component = fixture.componentInstance as unknown as ViewScene;
// About 10 pc of frame either side of the centre.
engine.getCamera().position.setLength(21.4);
component.controls.update();
await advanceFrames(engine, 0.3);
const beforePlan = refocus.mock.calls.length;
component.display.update((display) => ({ ...display, plan: true }));
TestBed.tick();
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(beforePlan + 1);
// Harmless under perspective; under the plan it moves a star 250 pc deep by 4 pc, against a 2.5 pc margin.
orbit(component, 1);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(beforePlan + 2);
});
it('chooses again when the projection changes under a pose that has not moved at all', async () => {
await advanceFrames(engine, 0.3);
const before = refocus.mock.calls.length;
// The same place, direction and frame height, but a box instead of a frustum, which frames other stars.
const perspective = engine.getPerspectiveCamera();
const plan = (engine as unknown as { orthographic: THREE.OrthographicCamera }).orthographic;
plan.position.copy(perspective.position);
plan.quaternion.copy(perspective.quaternion);
engine.projection = 'orthographic';
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(before + 1);
});
it('holds a turn to the narrower side of a portrait frame', async () => {
const component = fixture.componentInstance as unknown as ViewScene;
engine.getPerspectiveCamera().aspect = 0.4;
engine.getPerspectiveCamera().updateProjectionMatrix();
await advanceFrames(engine, 0.3);
// Inside half the margin above and below, past half of it at the sides.
orbit(component, 2);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(2);
});
it('keeps up with a flight frame by frame, from the frame it comes back into parsec space', async () => {
const component = fixture.componentInstance as unknown as ViewScene & { galaxyGroup: THREE.Group; rig: { isAnimating: boolean } };
navigationStore.selectStar(SUN.id);
await flushAsync();
await advanceFrames(engine, 2.5);
refocus.mockClear();
navigationStore.selectStar(null);
await flushAsync();
let choicesOnReturningFrame = -1;
let flightFrames = 0;
let flightChoices = 0;
for (let frame = 0; frame < 80; frame++) {
const wasInSystem = !component.galaxyGroup.visible;
const before = refocus.mock.calls.length;
engine.tick(0.05);
await flushAsync(1);
if (wasInSystem && component.galaxyGroup.visible) {
choicesOnReturningFrame = refocus.mock.calls.length - before;
}
if (component.galaxyGroup.visible && component.rig.isAnimating) {
flightFrames++;
flightChoices += refocus.mock.calls.length - before;
}
}
// Chosen for the view in the very frame the camera jumps back, not up to a pass later.
expect(choicesOnReturningFrame).toBe(1);
// The return zooms out from inside the system to the opening view: more re-choices than one a
// pass could make, and every one of them for the view.
expect(flightChoices).toBeGreaterThan(Math.ceil((flightFrames * 0.05) / 0.2));
expect(refocus.mock.calls.every(([focus]) => focus.view !== undefined)).toBe(true);
});
it('chooses once for the whole sky on the way out to the Galaxy, then leaves them alone', async () => {
const component = fixture.componentInstance as unknown as ViewScene;
engine.getCamera().position.set(0, 0, 30000);
await advanceFrames(engine, 0.3);
const onArrival = refocus.mock.calls.length;
expect(refocus.mock.calls.at(-1)![0].view).toBeUndefined();
component.controls.target.set(500, 0, 0);
await advanceFrames(engine, 0.3);
component.controls.target.set(1500, 0, 0);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(onArrival);
expect(refocus.mock.calls.filter(([focus]) => focus.view === undefined)).toHaveLength(1);
});
});
describe('the local grid of distance rings', () => {
type GridScene = {
controls: { target: THREE.Vector3; update(): void };
display: { update(change: (display: HudDisplay) => HudDisplay): void };
localGridRadii: readonly number[];
};
it('sizes the rings by how far the frame reaches from the Sun, under either projection', async () => {
const component = fixture.componentInstance as unknown as GridScene;
const camera = engine.getCamera();
// Centred on a point 200 pc out along the galactic plane — where the rings are — seen from
// 20 pc above it. The rings have to reach it, and one of them has to cross the frame.
const normal = galacticNormal();
const centre = new THREE.Vector3(1, 0, 0).projectOnPlane(normal).normalize().multiplyScalar(200);
component.controls.target.copy(centre);
camera.position.copy(centre).addScaledVector(normal, 20);
component.controls.update();
await advanceFrames(engine, 0.3);
const underPerspective = [...component.localGridRadii];
component.display.update((display) => ({ ...display, plan: true }));
TestBed.tick();
await advanceFrames(engine, 0.3);
expect(underPerspective.at(-1)).toBeGreaterThanOrEqual(200);
// The frame is a band about 19 pc either side of 200 pc: rings out to 220 at a step sized to
// all 220 are 180 and 200, both of them off screen.
const halfHeight = engine.visibleHalfHeight(20);
expect(underPerspective.some((radius) => Math.abs(radius - 200) < halfHeight)).toBe(true);
// The plan view's wheel moves the frame rather than the camera, so "how far out the camera
// is" means something else there; what the rings have to cover does not.
expect([...component.localGridRadii]).toEqual(underPerspective);
});
it('measures the span in the plane the rings lie in, not through it', async () => {
const component = fixture.componentInstance as unknown as GridScene;
const camera = engine.getCamera();
// The same 200 pc out along the plane, but lifted 150 pc above it: 250 pc from the Sun as the
// crow flies, and still 200 pc out among the rings, which is the distance they are drawn at.
const normal = galacticNormal();
const centre = new THREE.Vector3(1, 0, 0).projectOnPlane(normal).normalize().multiplyScalar(200).addScaledVector(normal, 150);
component.controls.target.copy(centre);
camera.position.copy(centre).addScaledVector(normal, 20);
component.controls.update();
await advanceFrames(engine, 0.3);
const halfHeight = engine.visibleHalfHeight(20);
expect([...component.localGridRadii].some((radius) => Math.abs(radius - 200) < halfHeight)).toBe(true);
});
it('leaves the rings alone while the grid is not drawn', async () => {
const component = fixture.componentInstance as unknown as GridScene;
const camera = engine.getCamera();
await advanceFrames(engine, 0.3);
component.display.update((display) => ({ ...display, grid: false }));
TestBed.tick();
await advanceFrames(engine, 0.3);
const hidden = [...component.localGridRadii];
// A zoom this size crosses two round steps, and each crossing rebuilds every ring's vertices.
camera.position.setLength(camera.position.length() / 8);
component.controls.update();
await advanceFrames(engine, 0.3);
expect([...component.localGridRadii]).toEqual(hidden);
});
it('drops a ring label that a star name has taken, or that is off screen, and keeps the ladder otherwise', () => {
const component = fixture.componentInstance as unknown as {
ringLabelsInTheClear(candidates: readonly LabeledPoint[], camera: THREE.Camera, stars: readonly LabeledPoint[]): LabeledPoint[];
};
const camera = engine.getCamera();
camera.updateMatrixWorld(true);
const at = (x: number, y: number) => new THREE.Vector3(x, y, 0.5).unproject(camera);
// Rungs at a twentieth of the screen: well inside the separation two names would keep, and
// well outside the clearance a ring label keeps from a name, so neither test is a coin toss.
const near = at(0.1, 0.1);
const nextRungUp = at(0.1, 0.18);
const offScreen = at(1.6, 0.1);
const ladder: LabeledPoint[] = [
{ id: 'ring-50', name: '50 pc', x: near.x, y: near.y, z: near.z },
{ id: 'ring-100', name: '100 pc', x: nextRungUp.x, y: nextRungUp.y, z: nextRungUp.z },
{ id: 'ring-150', name: '150 pc', x: offScreen.x, y: offScreen.y, z: offScreen.z }
];
// A ladder of rings stays whole, though its rungs are closer than two star names would be.
expect(component.ringLabelsInTheClear(ladder, camera, []).map((label) => label.id)).toEqual(['ring-50', 'ring-100']);
// A star's name is worth more than a distance.
const star: LabeledPoint = { id: 7, name: 'Sirius', x: near.x, y: near.y, z: near.z };
expect(component.ringLabelsInTheClear(ladder, camera, [star]).map((label) => label.id)).toEqual(['ring-100']);
});
it('stays out of the text of a name, not just off its point', () => {
const component = fixture.componentInstance as unknown as {
ringLabelsInTheClear(candidates: readonly LabeledPoint[], camera: THREE.Camera, stars: readonly LabeledPoint[]): LabeledPoint[];
viewportAspect(): number;
};
const camera = engine.getCamera();
camera.updateMatrixWorld(true);
const aspect = component.viewportAspect();
const at = (x: number, y: number) => new THREE.Vector3(x / aspect, y, 0.5).unproject(camera);
// A hand's breadth apart on screen — past any clearance around the point — and on the same
// line, with the name's text running right through where the ring label starts.
const ring = at(0.125, -0.123);
const rung: LabeledPoint = { id: 'ring-50', name: '50 pc', x: ring.x, y: ring.y, z: ring.z };
const beside = at(0.06, -0.12);
const rightHand: LabeledPoint = { id: 7, name: 'Alpha Centauri', side: 'right', x: beside.x, y: beside.y, z: beside.z };
expect(component.ringLabelsInTheClear([rung], camera, [rightHand])).toEqual([]);
// The same name hanging the other way leaves that space empty, and the rung with it.
expect(component.ringLabelsInTheClear([rung], camera, [{ ...rightHand, side: 'left' }])).toEqual([rung]);
// And a rung to the left of a name keeps its place: "50 pc" is a third of a star name's
// width, so it ends well before the name starts, whatever the anchors' spacing suggests.
const centred = at(0, 0);
const spanning: LabeledPoint = { id: 8, name: 'Alnitak', side: 'right', x: centred.x, y: centred.y, z: centred.z };
const toTheLeft = at(-0.25, 0.02);
const clearRung: LabeledPoint = { id: 'ring-100', name: '100 pc', x: toTheLeft.x, y: toTheLeft.y, z: toTheLeft.z };
expect(component.ringLabelsInTheClear([clearRung], camera, [spanning])).toEqual([clearRung]);
});
it('places the ring labels with the star names rather than over them', async () => {
const component = fixture.componentInstance as unknown as GridScene;
const update = vi.spyOn(StarLabelOverlay.prototype, 'update');
const cleared = vi.spyOn(GalaxySystemSceneComponent.prototype as unknown as { ringLabelsInTheClear: (...args: unknown[]) => LabeledPoint[] }, 'ringLabelsInTheClear');
const camera = engine.getCamera();
camera.position.set(0, 4, 10);
component.controls.target.set(0, 0, 0);
component.controls.update();
await advanceFrames(engine, 0.3);
const labels = (update.mock.calls.at(-1)?.[0] ?? []) as LabeledPoint[];
const rings = labels.filter((label) => String(label.id).startsWith('ring-'));
expect(rings.length).toBeGreaterThan(0);
// Handed over as the clearing pass left them, not as the grid produced them.
expect(cleared).toHaveBeenCalled();
expect(rings).toEqual(cleared.mock.results.at(-1)?.value);
update.mockRestore();
cleared.mockRestore();
});
});
it('keeps the stars of a plotted route drawn, and the selected star', async () => {
const component = fixture.componentInstance as unknown as { routeResult: { set(value: unknown): void } };
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
await advanceFrames(engine, 0.3);
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }, { id: PROXIMA.id, name: 'Proxima Centauri' }], totalPc: 1.3, neededRangePc: null, gaveUp: false, least: true });
await advanceFrames(engine, 0.3);
// As catalogue indices: the Sun is the first entry of STARS, Proxima the third.
expect(refocus.mock.calls.at(-1)![0].pinned).toEqual([0, 2]);
refocus.mockRestore();
});
describe('the jump-link graph', () => {
type LinkScene = {
routing: { links(rangePc: number, drawn: Uint32Array, budget?: LinkBudget): Promise<Float32Array>; route(): Promise<never>; dispose(): void };
display: { update(change: (display: { jumpLinks: boolean }) => unknown): void };
jumpRangePc: { set(rangePc: number): void };
routeResult: { set(value: unknown): void };
controls: { target: THREE.Vector3 };
starField: { drawnStars: Uint32Array; drawn: Uint32Array };
};
/** Real time, since the rebuild waits on a real timer for the range and the drawn stars to settle. */
const settle = () => new Promise((resolve) => setTimeout(resolve, 300));
function linkScene(links: LinkScene['routing']['links']): LinkScene {
const component = fixture.componentInstance as unknown as LinkScene;
component.routing = { links, route: () => new Promise<never>(() => undefined), dispose: () => undefined };
component.display.update((display) => ({ ...display, jumpLinks: true }));
TestBed.tick();
return component;
}
/** Makes the next refocus choose a different set: the field is told it draws one star, then the view moves. */
async function changeDrawnStars(component: LinkScene, targetX: number): Promise<void> {
component.starField.drawn = Uint32Array.of(0);
component.controls.target.set(targetX, 0, 0);
await advanceFrames(engine, 0.3);
}
it('links the stars being drawn, and asks again once a new set of them holds still', async () => {
const links = vi.fn((_rangePc: number, _drawn: Uint32Array) => Promise.resolve(new Float32Array(0)));
const component = linkScene(links);
await settle();
expect(links).toHaveBeenCalledTimes(1);
expect(links.mock.calls[0].slice(0, 2)).toEqual([3, component.starField.drawnStars]);
await changeDrawnStars(component, 40);
expect(links).toHaveBeenCalledTimes(1);
await settle();
expect(links).toHaveBeenCalledTimes(2);
expect(links.mock.calls[1][1]).toBe(component.starField.drawnStars);
expect(links.mock.calls[1][1]).not.toBe(links.mock.calls[0][1]);
// A route re-chooses the drawn stars around its pins, and here they come out the same: no new graph.
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }], totalPc: 0, neededRangePc: null, gaveUp: false, least: true });
await advanceFrames(engine, 0.3);
await settle();
expect(links).toHaveBeenCalledTimes(2);
});
it('asks for as much of the graph as a million pixels of line make, around where the view is centred', async () => {
const links = vi.fn((_rangePc: number, _drawn: Uint32Array, _budget?: LinkBudget) => Promise.resolve(new Float32Array(0)));
Object.defineProperty((fixture.nativeElement as HTMLElement).querySelector('canvas')!, 'clientHeight', { value: 1080 });
// A screen scaled to 200%: 1080 CSS pixels are 2160 drawn ones, and the lines are drawn in those.
engine.pixelRatio = 2;
linkScene(links);
await settle();
const budget = links.mock.calls[0][2];
// The view opens centred on the Sun: its frame's half-height there, over 1080 drawn pixels, is a pixel's worth of parsecs.
const halfHeight = engine.getCamera().position.length() * Math.tan((50 * Math.PI) / 360);
expect(budget?.centre).toEqual({ x: 0, y: 0, z: 0 });
expect(budget?.lengthPc).toBeCloseTo((1_000_000 * halfHeight) / 1080, 3);
});
it('asks again once the view has zoomed past the budget it asked with, though the drawn stars are the same', async () => {
// All three stars fit the star budget, so the drawn set never changes: only the budget can.
const links = vi.fn((_rangePc: number, _drawn: Uint32Array, _budget?: LinkBudget) => Promise.resolve(new Float32Array(0)));
Object.defineProperty((fixture.nativeElement as HTMLElement).querySelector('canvas')!, 'clientHeight', { value: 1080 });
const component = linkScene(links);
await advanceFrames(engine, 0.3);
await settle();
const asked = links.mock.calls.length;
const camera = engine.getCamera();
camera.position.sub(component.controls.target).multiplyScalar(0.5).add(component.controls.target);
await advanceFrames(engine, 0.3);
await settle();
expect(links.mock.calls.length).toBe(asked + 1);
expect(links.mock.calls.at(-1)![1]).toBe(links.mock.calls[0][1]);
});
it('asks for no graph from inside a system, where distances are in astronomical units', async () => {
const links = vi.fn((_rangePc: number, _drawn: Uint32Array, _budget?: LinkBudget) => Promise.resolve(new Float32Array(0)));
navigationStore.selectStar(SUN.id);
await flushAsync();
await advanceFrames(engine, 2.5);
linkScene(links);
await settle();
expect(links).not.toHaveBeenCalled();
});
it('keeps what it asked for when an older request it replaced is rejected', async () => {
// Off and on again while a graph is still waiting: the waiting one is replaced, and its
// rejection must not be taken for the request that replaced it.
const pending: Array<{ resolve: (segments: Float32Array) => void; reject: (error: Error) => void }> = [];
const setSegments = vi.spyOn(JumpLinkRenderer.prototype, 'setSegments');
const component = linkScene(() => new Promise<Float32Array>((resolve, reject) => pending.push({ resolve, reject })));
await settle();
component.display.update((display) => ({ ...display, jumpLinks: false }));
TestBed.tick();
await settle();
component.display.update((display) => ({ ...display, jumpLinks: true }));
TestBed.tick();
await settle();
expect(pending).toHaveLength(2);
pending[0].reject(new Error('Superseded by a newer request'));
await flushAsync();
const graph = new Float32Array(6);
pending[1].resolve(graph);
await flushAsync();
expect(setSegments).toHaveBeenLastCalledWith(graph);
setSegments.mockRestore();
});
it('gives a view on the move a new graph at least every quarter second, rather than waiting for it to stop', async () => {
const links = vi.fn((_rangePc: number, _drawn: Uint32Array) => Promise.resolve(new Float32Array(0)));
const component = linkScene(links);
await settle();
// A new drawn set about every 150 ms for a second, as an orbit makes one each pass.
for (let pass = 1; pass <= 7; pass++) {
await changeDrawnStars(component, pass * 40);
await new Promise((resolve) => setTimeout(resolve, 120));
}
expect(links.mock.calls.length).toBeGreaterThanOrEqual(3);
});
it('draws a late graph for the range still asked for, and not one for a range left behind', async () => {
const answers: Array<(segments: Float32Array) => void> = [];
const setSegments = vi.spyOn(JumpLinkRenderer.prototype, 'setSegments');
const component = linkScene(() => new Promise<Float32Array>((resolve) => answers.push(resolve)));
await settle();
await changeDrawnStars(component, 40);
await settle();
expect(answers).toHaveLength(2);
// For stars no longer drawn, but at the range still asked for: newer than what is on screen.
const olderSet = new Float32Array(6);
answers[0](olderSet);
await flushAsync();
expect(setSegments).toHaveBeenLastCalledWith(olderSet);
component.jumpRangePc.set(5);
TestBed.tick();
await settle();
expect(answers).toHaveLength(3);
answers[1](new Float32Array(12));
await flushAsync();
expect(setSegments).toHaveBeenLastCalledWith(olderSet);
const current = new Float32Array(18);
answers[2](current);
await flushAsync();
expect(setSegments).toHaveBeenLastCalledWith(current);
setSegments.mockRestore();
});
});
it('shows the answer to the latest route asked for, whatever order the answers arrive in', async () => {
type Answer = { route: { stars: number[]; totalPc: number; longestHopPc: number } | null; neededRangePc: number | null; gaveUp: boolean; least: boolean };
const answers: Array<(answer: Answer) => void> = [];
const component = fixture.componentInstance as unknown as {
routing: { route(): Promise<Answer>; links(): Promise<Float32Array>; dispose(): void };
routePending(): boolean;
routeResult(): { stars: { id: number }[]; gaveUp: boolean } | null;
onRouteRequested(request: { fromId: number; toId: number; rangePc: number }): void;
};
component.routing = {
route: () => new Promise<Answer>((resolve) => answers.push(resolve)),
links: () => Promise.resolve(new Float32Array(0)),
dispose: () => undefined
};
component.onRouteRequested({ fromId: SUN.id, toId: ALPHA_CENTAURI.id, rangePc: 2 });
component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 });
expect(component.routePending()).toBe(true);
answers[1]({ route: { stars: [SUN.id, PROXIMA.id], totalPc: 1.3, longestHopPc: 1.3 }, neededRangePc: null, gaveUp: false, least: true });
await flushAsync();
answers[0]({ route: { stars: [SUN.id, ALPHA_CENTAURI.id], totalPc: 1.34, longestHopPc: 1.34 }, neededRangePc: null, gaveUp: false, least: true });
await flushAsync();
expect(component.routeResult()?.stars.map((star) => star.id)).toEqual([SUN.id, PROXIMA.id]);
expect(component.routePending()).toBe(false);
// "It gave up" travels to the panel, which says something else for it than for "there is none".
component.onRouteRequested({ fromId: SUN.id, toId: ALPHA_CENTAURI.id, rangePc: 0.5 });
answers[2]({ route: null, neededRangePc: null, gaveUp: true, least: false });
await flushAsync();
expect(component.routeResult()).toMatchObject({ stars: [], gaveUp: true });
});
it('releases the routes panel when a route cannot be worked out, so it can be tried again', async () => {
const component = fixture.componentInstance as unknown as {
routing: { route(): Promise<never>; links(): Promise<Float32Array>; dispose(): void };
routePending(): boolean;
onRouteRequested(request: { fromId: number; toId: number; rangePc: number }): void;
};
const logged = vi.spyOn(console, 'error').mockImplementation(() => undefined);
component.routing = { route: () => Promise.reject(new Error('worker gone')), links: () => Promise.resolve(new Float32Array(0)), dispose: () => undefined };
component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 });
await flushAsync();
expect(component.routePending()).toBe(false);
expect(logged).toHaveBeenCalled();
logged.mockRestore();
});
it('asks for no more label candidates once the last label it will show is placed', () => {
// Near the Sun a label candidate past the fifteenth can sit at the far end of the catalogue's
// brightness order, so asking for one more than is used can cost a walk of the whole order.
const component = fixture.componentInstance as unknown as {
spreadLabels(candidates: Iterable<{ id: number; name: string; x: number; y: number; z: number }>, camera: THREE.Camera, keepId: null): unknown[];
};
const camera = engine.getCamera();
camera.updateMatrixWorld(true);
camera.updateProjectionMatrix();
let pulled = 0;
const grid = function* () {
for (let row = 0; row < 5; row++) {
for (let column = 0; column < 5; column++) {
pulled++;
const point = new THREE.Vector3(-0.8 + column * 0.4, -0.8 + row * 0.4, 0.5).unproject(camera);
yield { id: row * 5 + column, name: `label-${pulled}`, x: point.x, y: point.y, z: point.z };
}
}
};
expect(component.spreadLabels(grid(), camera, null)).toHaveLength(15);
expect(pulled).toBe(15);
});
it('flies the camera into a selected star system: hides the galaxy group, shows the system group, and switches to AU-scale near/far planes', async () => { it('flies the camera into a selected star system: hides the galaxy group, shows the system group, and switches to AU-scale near/far planes', async () => {
navigationStore.selectStar(SUN.id); navigationStore.selectStar(SUN.id);
await flushAsync(); await flushAsync();
@@ -185,6 +834,111 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
expect(navigationStore.viewLevel()).toBe('system'); expect(navigationStore.viewLevel()).toBe('system');
}); });
it('says where a system’s orbits come from, and for the Sun how long they hold', async () => {
const note = (): string => (fixture.componentInstance as unknown as { hudNote: () => string }).hudNote();
navigationStore.selectStar(SUN.id);
await flushAsync();
await advanceFrames(engine, 2.5);
expect(note()).toMatch(/^Orbits propagated from JPL mean elements, the planets’ fit for 3000 BC to AD 3000 and the moons’ checked from 1950 to 2100, and the SBDB’s osculating ones for Ceres, Eris, Haumea and Makemake, checked over the same span, to now, \d{4}-\d\d-\d\d \d\d:\d\d UTC\.$/);
navigationStore.selectStar(ALPHA_CENTAURI.id);
await flushAsync();
await advanceFrames(engine, 5);
expect(note()).toMatch(/^Orbits propagated from published elements to now, \d{4}-\d\d-\d\d \d\d:\d\d UTC\.$/);
});
it('turns the Sun about its IAU pole, once in 25.38 days', async () => {
const time = TestBed.inject(TimeStore);
time.setRate(0);
time.setDate(new Date('2026-01-01T00:00Z'));
navigationStore.selectStar(SUN.id);
await flushAsync();
await advanceFrames(engine, 2.5);
const sun = (): THREE.Object3D => (fixture.componentInstance as unknown as { starMarker: THREE.Object3D }).starMarker;
const turned = (local: THREE.Vector3): THREE.Vector3 => local.applyQuaternion(sun().getWorldQuaternion(new THREE.Quaternion()));
// The sphere's +Y, which MAP_TO_BODY carries onto the body's pole, at RA 286.13, Dec 63.87.
const ra = (286.13 * Math.PI) / 180;
const dec = (63.87 * Math.PI) / 180;
const pole = new THREE.Vector3(Math.cos(dec) * Math.cos(ra), Math.cos(dec) * Math.sin(ra), Math.sin(dec));
expect(turned(new THREE.Vector3(0, 1, 0)).angleTo(pole)).toBeLessThan(1e-6);
const before = turned(new THREE.Vector3(1, 0, 0));
time.setDate(new Date('2026-01-02T00:00Z'));
await advanceFrames(engine, 0.1);
expect((turned(new THREE.Vector3(1, 0, 0)).angleTo(before) * 180) / Math.PI).toBeCloseTo(14.1844, 3);
});
it('names the date the system is drawn for once the clock is set to one', async () => {
const note = (): string => (fixture.componentInstance as unknown as { hudNote: () => string }).hudNote();
TestBed.inject(TimeStore).setDate(new Date('2020-12-21T18:00Z'));
navigationStore.selectStar(SUN.id);
await flushAsync();
await advanceFrames(engine, 2.5);
// The great conjunction, to the minute: not "now", and not a date the reader has to find.
expect(note()).toMatch(/ to 2020-12-21 18:00 UTC\.$/);
});
describe('with Eris, whose aphelion runs past the grid', () => {
type FramedScene = { bodies: BodyRecord[]; controls: { target: THREE.Vector3 }; systemRenderer: SystemOrbitsRenderer; systemGroup: THREE.Group };
// Its 67.9 AU axis gives the grid an 80 AU outer ring; at aphelion it is 97.7 AU out.
const ERIS: BodyRecord = {
...EARTH, id: 'eris', name: 'Eris', kind: 'dwarf', radiusKm: 1163,
orbit: { ...EARTH.orbit, semiMajorAxisAu: 67.934, eccentricity: 0.4382 }, rates: keplerRates(67.934, GM_SUN_AU3_PER_DAY2)
};
async function enterTheSun(aspect: number): Promise<FramedScene> {
const component = fixture.componentInstance as unknown as FramedScene;
component.bodies = [EARTH, ERIS];
engine.getPerspectiveCamera().aspect = aspect;
navigationStore.selectStar(SUN.id);
await flushAsync();
await advanceFrames(engine, 2.5);
return component;
}
it('frames the furthest the system draws, Eris’s aphelion, not the ring inside it nor its semi-major axis', async () => {
const component = await enterTheSun(1);
const camera = engine.getPerspectiveCamera();
expect(component.systemRenderer.outermostRadiusAu).toBeCloseTo(67.934 * 1.4382, 9);
// 234.7 AU; framed on the 67.9 AU axis the camera would stand at 163 AU and Eris arrive off screen.
expect(camera.position.distanceTo(component.controls.target)).toBeCloseTo(
systemFramingDistanceAu(component.systemRenderer.outermostRadiusAu, { fovDegrees: camera.fov, aspect: camera.aspect }),
6
);
});
it('holds Earth to its 3-pixel floor at the arrival framing, where its true radius is far under a pixel', async () => {
Object.defineProperty((fixture.nativeElement as HTMLElement).querySelector('canvas')!, 'clientHeight', { value: 1000 });
const component = await enterTheSun(1.6);
const earth = component.systemRenderer.members.find((member) => member.id === 'earth')!.marker as THREE.Mesh;
const pixelAu = (2 * engine.visibleHalfHeight(engine.getCamera().position.distanceTo(earth.getWorldPosition(new THREE.Vector3())))) / 1000;
expect((earth.scale.x * (earth.geometry as THREE.SphereGeometry).parameters.radius) / pixelAu).toBeCloseTo(3, 3);
});
it('leaves the system outwards even from a phone’s framing, which stands past the 400 AU it used to fly to', async () => {
const component = await enterTheSun(390 / 844);
const camera = engine.getCamera();
const arrival = camera.position.length();
expect(arrival).toBeGreaterThan(500);
navigationStore.selectStar(null);
await flushAsync(1);
// Until the swap: it flew 508 AU in to 400, and the system grew on screen while the reader left it.
let previous = arrival;
for (let frame = 0; frame < 100 && component.systemGroup.visible; frame++) {
engine.tick(0.05);
await flushAsync(1);
if (component.systemGroup.visible) {
expect(camera.position.length()).toBeGreaterThanOrEqual(previous - 1e-9);
previous = camera.position.length();
}
}
expect(component.systemGroup.visible).toBe(false);
expect(previous).toBeGreaterThan(arrival);
});
});
it('performs the floating-origin recenter: the camera lands close to the AU-space origin, not out at parsec-scale coordinates', async () => { it('performs the floating-origin recenter: the camera lands close to the AU-space origin, not out at parsec-scale coordinates', async () => {
navigationStore.selectStar(ALPHA_CENTAURI.id); navigationStore.selectStar(ALPHA_CENTAURI.id);
await flushAsync(); await flushAsync();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
import * as THREE from 'three/webgpu';
import { color, float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
import { StarRecord } from '../../shared/models/star.model';
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES } from './angular-size';
/** Ring diameter in screen pixels at the reference viewport — angular, like the star points. */
const RING_SIZE_PX = 12;
const RING_PEAK_OPACITY = 0.35;
/** Ring radius and stroke half-width in quad-uv units (the quad runs 0..1, centre 0.5). */
const RING_RADIUS_UV = 0.42;
const RING_STROKE_UV = 0.06;
/** A unit quad centred on the origin — the billboard every ring instance is drawn on. */
function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometry {
const geometry = new THREE.InstancedBufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]), 3));
geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), 2));
geometry.setIndex([0, 1, 2, 0, 2, 3]);
geometry.instanceCount = instanceCount;
return geometry;
}
/**
* A faint accent ring around every star known to host planets: the one binary fact about a
* point of light worth reading at a glance from the neighbourhood view, since it is the one
* thing that says "there is somewhere to go here".
*
* Drawn the way the star field draws its stars — instanced unattenuated sprites — so the rings
* sit exactly on the field's own points at any zoom and window size. The ring itself is a band
* of the quad's uv distance from centre, not a texture, so it stays a hairline at any scale.
*/
export class HostStarRings {
readonly object: THREE.Mesh;
readonly count: number;
private readonly geometry: THREE.InstancedBufferGeometry;
private readonly material: THREE.SpriteNodeMaterial;
private readonly opacity = uniform(RING_PEAK_OPACITY);
/** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */
private readonly perspective = uniform(1);
private readonly orthographicScale = uniform(float(0));
constructor(hosts: readonly StarRecord[], accent: number) {
const positions = new Float32Array(hosts.length * 3);
hosts.forEach((star, i) => positions.set([star.x, star.y, star.z], i * 3));
this.geometry = createQuadGeometry(hosts.length);
this.material = new THREE.SpriteNodeMaterial({ transparent: true, depthWrite: false });
// As in the star field: the angular-to-world conversion is done here rather than by
// `sizeAttenuation: false`, which three.js applies only under a perspective camera.
this.material.sizeAttenuation = true;
const position = instancedBufferAttribute<'vec3'>(new THREE.InstancedBufferAttribute(positions, 3), 'vec3');
this.material.positionNode = position;
const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate();
this.material.scaleNode = float(RING_SIZE_PX * PIXELS_TO_ANGULAR_SIZE).mul(mix(this.orthographicScale, viewDepth, this.perspective));
this.material.colorNode = color(accent);
// Opaque on the ring's centreline, falling to nothing one stroke-width either side.
const distanceFromRing = uv().sub(vec2(0.5)).length().sub(RING_RADIUS_UV).abs();
this.material.opacityNode = smoothstep(RING_STROKE_UV, 0.0, distanceFromRing).mul(this.opacity);
this.object = new THREE.Mesh(this.geometry, this.material);
this.object.name = 'host-star-rings';
// As for the star field: the quad's bounds say nothing about where the instances are.
this.object.frustumCulled = false;
this.count = hosts.length;
}
/** Which projection the rings are drawn under; see `StarFieldRenderer.setProjection`. */
setProjection(halfHeightWorld: number | null): void {
this.perspective.value = halfHeightWorld === null ? 1 : 0;
this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360);
}
/** Crossfaded with the local grid: from outside the Galaxy the rings are noise. */
setStrength(strength: number): void {
const clamped = THREE.MathUtils.clamp(strength, 0, 1);
this.opacity.value = RING_PEAK_OPACITY * clamped;
this.object.visible = clamped > 0;
}
dispose(): void {
this.geometry.dispose();
this.material.dispose();
}
}
@@ -0,0 +1,102 @@
import * as THREE from 'three/webgpu';
/** Faint, because there are tens of thousands of them and none is worth reading on its own. */
const LINK_OPACITY = 0.16;
/** The one route is the figure; the graph it is drawn on is the ground. */
const ROUTE_OPACITY = 0.9;
/**
* How far the graph falls back while a route is up. Near the Sun the catalogue is dense enough
* that the links are a solid haze, and a chain drawn through it would be one bright thread in a
* bright cloud; stepping the ground down is what makes the figure a figure.
*/
const GROUND_WHILE_ROUTED = 0.4;
export interface LinkPoint {
readonly x: number;
readonly y: number;
readonly z: number;
}
/**
* The jump-link graph, and one route through it.
*
* Both are line segments in the galaxy's parsec frame: the graph as a single buffer, because a
* pair of vertices per link is the cheapest way to draw a hundred thousand of them, and the
* route as a second, brighter one over the top. The route is a strip rather than a set of pairs
* so a chain of hops reads as one continuous thing.
*/
export class JumpLinkRenderer {
readonly object = new THREE.Group();
private readonly linkMaterial: THREE.LineBasicMaterial;
private readonly routeMaterial: THREE.LineBasicMaterial;
private readonly links: THREE.LineSegments;
private readonly route: THREE.Line;
private strength = 1;
private routed = false;
constructor(accent: THREE.ColorRepresentation) {
this.linkMaterial = new THREE.LineBasicMaterial({ color: accent, transparent: true, opacity: LINK_OPACITY, depthWrite: false });
this.routeMaterial = new THREE.LineBasicMaterial({ color: accent, transparent: true, opacity: ROUTE_OPACITY, depthWrite: false });
this.links = new THREE.LineSegments(new THREE.BufferGeometry(), this.linkMaterial);
this.route = new THREE.Line(new THREE.BufferGeometry(), this.routeMaterial);
// Both are rebuilt from scratch whenever they change, so their bounds are only ever right
// by accident between rebuilds; culling on a stale sphere drops the graph mid-pan.
this.links.frustumCulled = false;
this.route.frustumCulled = false;
this.object.add(this.links, this.route);
this.setSegments(new Float32Array(0));
this.setRoute([], () => undefined);
}
/** The graph, as vertex pairs: six floats a link, one end then the other. See `jumpLinkSegments`. */
setSegments(vertices: Float32Array): void {
this.replaceGeometry(this.links, vertices);
// Given rather than left for the renderer to compute: it wants a bounding sphere to sort by and,
// finding none, walks every vertex on the main thread in the first frame that draws the graph.
// The graph is never culled, and it sorts by its centre, where the catalogue is centred too.
this.links.geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), Infinity);
}
/** The chain to draw over the graph, departure first. Fewer than two stars draws nothing. */
setRoute(starIds: readonly number[], positionOf: (starId: number) => LinkPoint | undefined): void {
const points = starIds.map(positionOf).filter((point): point is LinkPoint => point !== undefined);
this.routed = points.length >= 2;
this.applyOpacity();
const vertices = new Float32Array(points.length < 2 ? 0 : points.length * 3);
points.forEach((point, i) => {
if (vertices.length > 0) {
vertices.set([point.x, point.y, point.z], i * 3);
}
});
this.replaceGeometry(this.route, vertices);
}
/** Crossfaded with the local layer: from outside the Galaxy the graph is a smear. */
setStrength(strength: number): void {
this.strength = THREE.MathUtils.clamp(strength, 0, 1);
this.applyOpacity();
this.object.visible = this.strength > 0;
}
private applyOpacity(): void {
this.linkMaterial.opacity = LINK_OPACITY * this.strength * (this.routed ? GROUND_WHILE_ROUTED : 1);
this.routeMaterial.opacity = ROUTE_OPACITY * this.strength;
}
dispose(): void {
this.links.geometry.dispose();
this.route.geometry.dispose();
this.linkMaterial.dispose();
this.routeMaterial.dispose();
}
private replaceGeometry(target: THREE.LineSegments | THREE.Line, vertices: Float32Array): void {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
const previous = target.geometry;
target.geometry = geometry;
previous.dispose();
}
}
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { ReservedBox, ringPlacement } from './label-ring';
const VIEWPORT = { width: 1440, height: 900 };
/** Where a placement lands on screen, which is what the rule is really about. */
function screen(placement: { x: number; y: number }): { x: number; y: number } {
return { x: Math.round(((placement.x + 1) / 2) * VIEWPORT.width), y: Math.round(((1 - placement.y) / 2) * VIEWPORT.height) };
}
describe('ringPlacement', () => {
it('puts a name where its bearing points, on a ring that is round on screen', () => {
const right = ringPlacement(0, 0.74, VIEWPORT);
const up = ringPlacement(Math.PI / 2, 0.74, VIEWPORT);
// Same distance from the centre in pixels, despite the frame being wider than it is tall.
const centre = { x: VIEWPORT.width / 2, y: VIEWPORT.height / 2 };
const radius = (p: { x: number; y: number }) => Math.hypot(screen(p).x - centre.x, screen(p).y - centre.y);
expect(radius(right!)).toBeCloseTo(radius(up!), 0);
expect(screen(right!).y).toBe(450);
expect(screen(up!).x).toBe(720);
});
it('leaves the bearing alone when nothing is in the way', () => {
expect(ringPlacement(1.1, 0.74, VIEWPORT)?.angle).toBeCloseTo(1.1);
});
it('slides a name along the ring rather than printing it behind a panel', () => {
// The readout panel, bottom left, where the ring passes.
const readout: ReservedBox = { left: 24, top: 640, right: 536, bottom: 830 };
const behindIt = (5 * Math.PI) / 4;
const placed = ringPlacement(behindIt, 0.74, VIEWPORT, [readout]);
expect(placed).not.toBeNull();
expect(placed!.angle).not.toBeCloseTo(behindIt);
const { x, y } = screen(placed!);
expect(x > readout.right || x < readout.left || y < readout.top || y > readout.bottom).toBe(true);
});
it('moves it the smallest distance that clears, and to the nearer side', () => {
const box: ReservedBox = { left: 0, top: 0, right: 1440, bottom: 200 };
const straightUp = Math.PI / 2;
const placed = ringPlacement(straightUp, 0.74, VIEWPORT, [box]);
expect(placed).not.toBeNull();
expect(Math.abs(placed!.angle - straightUp)).toBeLessThanOrEqual(Math.PI / 3);
});
it('gives up rather than half-hide a name, when everything near its bearing is covered', () => {
const wall: ReservedBox = { left: 0, top: 0, right: 1440, bottom: 900 };
expect(ringPlacement(0, 0.74, VIEWPORT, [wall])).toBeNull();
});
it('counts the width of the text, not just the point it hangs from', () => {
// A panel the anchor clears by 40px but the text does not.
const justRight: ReservedBox = { left: 1150, top: 400, right: 1440, bottom: 500 };
const placed = ringPlacement(0, 0.74, VIEWPORT, [justRight]);
expect(placed!.angle).not.toBeCloseTo(0);
});
it('fits a frame held upright, where sizing against the height alone would miss it entirely', () => {
const phone = { width: 390, height: 844 };
const right = ringPlacement(0, 0.74, phone);
const up = ringPlacement(Math.PI / 2, 0.74, phone);
expect(Math.abs(right!.x)).toBeLessThanOrEqual(1);
expect(Math.abs(up!.y)).toBeLessThanOrEqual(1);
// Still a circle: the same number of pixels out, whichever way it is measured.
expect(Math.abs(right!.x) * (phone.width / 2)).toBeCloseTo(Math.abs(up!.y) * (phone.height / 2), 0);
});
it('survives a viewport with no height rather than dividing by it', () => {
expect(ringPlacement(0, 0.74, { width: 0, height: 0 })).not.toBeNull();
});
});
@@ -0,0 +1,86 @@
/**
* Where a name goes on the ring around the view, given the direction it stands for and the
* panels already occupying the frame.
*
* Pure and in screen space, so the rule can be read and tested without a scene: the caller turns
* a direction into an angle, this decides where on the ring that angle can actually be printed,
* and the caller turns the answer back into a point the renderer can project.
*/
/** A box the ring must not print into, in pixels from the top-left of the viewport. */
export interface ReservedBox {
readonly left: number;
readonly top: number;
readonly right: number;
readonly bottom: number;
}
export interface RingViewport {
readonly width: number;
readonly height: number;
}
/** A place on the ring, in normalised device coordinates (-1..1, y up). */
export interface RingPlacement {
readonly x: number;
readonly y: number;
/** The angle actually used, which is the requested one unless a panel was in the way. */
readonly angle: number;
}
/**
* How far the bearing may be bent to get out from behind a panel, and in what steps. Bending is
* a lie about the direction, so it is kept small and always tried in the smallest amount that
* works, alternating sides so the name ends up on whichever side of the panel is nearer.
*/
const MAX_NUDGE_RADIANS = Math.PI / 3;
const NUDGE_STEP_RADIANS = Math.PI / 24;
/**
* The label's text runs this far from its anchor, as a fraction of the viewport width, and this
* tall. A name clears a panel only if the whole line does, not just the point it hangs from.
*/
const LABEL_REACH_FRACTION = 0.13;
const LABEL_HEIGHT_PX = 30;
function overlaps(x: number, y: number, viewport: RingViewport, reserved: readonly ReservedBox[]): boolean {
const px = ((x + 1) / 2) * viewport.width;
const py = ((1 - y) / 2) * viewport.height;
const reach = viewport.width * LABEL_REACH_FRACTION;
// Either side, because which side the text hangs on is decided later, by the label pass.
const left = px - reach;
const right = px + reach;
const top = py - LABEL_HEIGHT_PX / 2;
const bottom = py + LABEL_HEIGHT_PX / 2;
return reserved.some((box) => left < box.right && right > box.left && top < box.bottom && bottom > box.top);
}
/**
* Places one name on the ring at `angle`, moved along the ring if a panel is in the way, or
* `null` if the whole neighbourhood of that angle is covered — better absent than half hidden
* behind a readout.
*
* `radius` is a fraction of the frame's shorter side, so the ring is a circle on screen — and
* fits whichever way up the frame is. Sizing it against the height alone puts the ring a
* viewport and a half wide on a phone held upright, which is to say off both edges.
*/
export function ringPlacement(
angle: number,
radius: number,
viewport: RingViewport,
reserved: readonly ReservedBox[] = []
): RingPlacement | null {
const shorterSide = Math.min(viewport.width, viewport.height);
const scaleX = viewport.width === 0 ? radius : (radius * shorterSide) / viewport.width;
const scaleY = viewport.height === 0 ? radius : (radius * shorterSide) / viewport.height;
for (let nudge = 0; nudge <= MAX_NUDGE_RADIANS; nudge += NUDGE_STEP_RADIANS) {
for (const candidate of nudge === 0 ? [angle] : [angle + nudge, angle - nudge]) {
const x = Math.cos(candidate) * scaleX;
const y = Math.sin(candidate) * scaleY;
if (!overlaps(x, y, viewport, reserved)) {
return { x, y, angle: candidate };
}
}
}
return null;
}
@@ -0,0 +1,259 @@
import { describe, expect, it } from 'vitest';
import { jumpLinkSegments, routeBetween } from '../../shared/astro/jump-links';
import { RoutingRequest, RoutingResponse } from '../../shared/astro/routing';
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
import { StarRecord } from '../../shared/models/star.model';
import { RoutingClient, SupersededRequest } from './routing-client';
const STARS: StarRecord[] = Array.from({ length: 6 }, (_, i) => ({
id: 100 + i,
name: `star-${i}`,
x: i < 5 ? i : 9,
y: 0,
z: 0,
magnitude: 5,
spectralType: 'G2V',
colorIndex: 0.6
}));
const POSITIONS = Float32Array.from(STARS.flatMap((star) => [star.x, star.y, star.z]));
const index = new StarNeighbourhood(STARS);
/** Every star drawn. */
const ALL = Uint32Array.from(STARS.keys());
/** Flushes settled promises and their handlers. */
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
/** A worker that records what it is sent and answers only when told to. */
class FakeWorker {
readonly sent: Array<RoutingRequest | { kind: 'catalogue' }> = [];
readonly transferred: ArrayBufferLike[] = [];
private readonly listeners: Record<string, Array<(event: { data?: unknown }) => void>> = {};
terminated = false;
postMessage(message: RoutingRequest | { kind: 'catalogue' }, transfer: Transferable[] = []): void {
this.sent.push(message);
this.transferred.push(...(transfer as ArrayBufferLike[]));
}
addEventListener(type: string, listener: (event: { data?: unknown }) => void): void {
(this.listeners[type] ??= []).push(listener);
}
terminate(): void {
this.terminated = true;
}
/** The requests sent so far, catalogue aside. */
get requests(): RoutingRequest[] {
return this.sent.filter((message): message is RoutingRequest => message.kind !== 'catalogue');
}
answer(response: RoutingResponse): void {
for (const listener of this.listeners['message'] ?? []) listener({ data: response });
}
fail(): void {
for (const listener of this.listeners['error'] ?? []) listener({});
}
}
function clientWithFake(): { client: RoutingClient; worker: FakeWorker } {
const worker = new FakeWorker();
const client = new RoutingClient(STARS, POSITIONS, index, () => worker as unknown as Worker);
return { client, worker };
}
// The unit tests' DOM has no Worker, which is exactly the case the client answers in place.
describe('RoutingClient without a worker', () => {
it('has no Worker to use here, so the in-place answers are what is being tested', () => {
expect(typeof Worker).toBe('undefined');
});
it('answers a route from the index it was given', async () => {
const client = new RoutingClient(STARS, POSITIONS, index);
await expect(client.route(100, 104, 1.5, 8)).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5).route, neededRangePc: null, gaveUp: false, least: true });
client.dispose();
});
it('answers a refused route with the range that would open it', async () => {
const client = new RoutingClient(STARS, POSITIONS, index);
const answer = await client.route(100, 105, 1.5, 8);
expect(answer.route).toBeNull();
expect(answer.neededRangePc).toBeCloseTo(5, 1);
client.dispose();
});
it('answers the graph as segments', async () => {
const client = new RoutingClient(STARS, POSITIONS, index);
expect(Array.from(await client.links(1.5, ALL))).toEqual(Array.from(jumpLinkSegments(index, 1.5)));
client.dispose();
});
it('keeps only the links its budget holds, nearest the centre first', async () => {
const client = new RoutingClient(STARS, POSITIONS, index);
// Stars at x = 0 to 4 a parsec apart: from a centre at 3.9, one and a half parsecs is the link 3-4 alone.
const segments = await client.links(1.5, ALL, { centre: { x: 3.9, y: 0, z: 0 }, lengthPc: 1.5 });
expect(Array.from(segments)).toEqual([3, 0, 0, 4, 0, 0]);
client.dispose();
});
it('links only the stars it is told are drawn', async () => {
const client = new RoutingClient(STARS, POSITIONS, index);
// Stars at x = 0, 1 and 3: only the first two are within 1.5 pc of each other.
expect(Array.from(await client.links(1.5, Uint32Array.of(0, 1, 3)))).toEqual([0, 0, 0, 1, 0, 0]);
client.dispose();
});
});
describe('RoutingClient with a worker', () => {
it('sends the catalogue first, then one request at a time', () => {
const { client, worker } = clientWithFake();
void client.links(8, ALL);
void client.links(3, ALL);
expect(worker.sent[0].kind).toBe('catalogue');
expect(worker.requests).toHaveLength(1);
client.dispose();
});
// A graph at 8 pc is seconds of work the worker cannot drop once started. Every pause on the
// range slider used to queue another, and a route asked for after them waited behind them all.
it('replaces a waiting graph with the newer one before it is ever built, and sends a route ahead of it', async () => {
const { client, worker } = clientWithFake();
const first = client.links(5, ALL);
const superseded = client.links(6, ALL).catch((error: unknown) => error);
const latest = client.links(8, ALL);
const route = client.route(100, 104, 1.5, 8);
const building = worker.requests[0];
worker.answer({ kind: 'links', requestId: building.requestId, segments: new Float32Array(6) });
await flush();
expect(await superseded).toBeInstanceOf(SupersededRequest);
expect(worker.requests.map((request) => request.kind)).toEqual(['links', 'route']);
await expect(first).resolves.toHaveLength(6);
const routeRequest = worker.requests[1];
worker.answer({ kind: 'route', requestId: routeRequest.requestId, route: null, neededRangePc: 4, gaveUp: false, least: true });
await expect(route).resolves.toEqual({ route: null, neededRangePc: 4, gaveUp: false, least: true });
await flush();
expect(worker.requests.map((request) => (request.kind === 'links' ? request.rangePc : request.kind))).toEqual([5, 'route', 8]);
const lastGraph = worker.requests[2];
worker.answer({ kind: 'links', requestId: lastGraph.requestId, segments: new Float32Array(12) });
await expect(latest).resolves.toHaveLength(12);
client.dispose();
});
it('shares the answer to a route already on its way rather than asking it twice', async () => {
const { client, worker } = clientWithFake();
const once = client.route(100, 104, 1.5, 8);
const again = client.route(100, 104, 1.5, 8);
const widerRange = client.route(100, 104, 2.5, 8);
expect(worker.requests).toHaveLength(1);
worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: 4, gaveUp: false, least: true });
expect(await again).toEqual(await once);
await flush();
// The same two stars at another range is another question.
expect(worker.requests.map((request) => request.rangePc)).toEqual([1.5, 2.5]);
worker.answer({ kind: 'route', requestId: worker.requests[1].requestId, route: null, neededRangePc: null, gaveUp: false, least: true });
await expect(widerRange).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: false, least: true });
client.dispose();
});
// Turning the layer off and on again while the worker is busy asks for the same graph twice. Were
// the second to replace the first, the first's rejection would wipe the scene's record of the second.
it('shares a graph already on its way for the same range and the same list of drawn stars', async () => {
const { client, worker } = clientWithFake();
const drawn = Uint32Array.of(0, 1, 2);
const building = client.links(3, drawn);
const waiting = client.links(5, drawn);
const again = client.links(5, drawn);
const sameAsBuilding = client.links(3, drawn);
worker.answer({ kind: 'links', requestId: worker.requests[0].requestId, segments: new Float32Array(6) });
await expect(building).resolves.toHaveLength(6);
await expect(sameAsBuilding).resolves.toHaveLength(6);
await flush();
worker.answer({ kind: 'links', requestId: worker.requests[1].requestId, segments: new Float32Array(12) });
await expect(waiting).resolves.toHaveLength(12);
await expect(again).resolves.toHaveLength(12);
expect(worker.requests.map((request) => request.kind === 'links' && request.rangePc)).toEqual([3, 5]);
client.dispose();
});
it('builds a graph for each set of drawn stars asked about, and never gives the list away', async () => {
const { client, worker } = clientWithFake();
const near = Uint32Array.of(0, 1, 2);
const far = Uint32Array.of(3, 4, 5);
void client.links(3, near);
const second = client.links(3, far);
worker.answer({ kind: 'links', requestId: worker.requests[0].requestId, segments: new Float32Array(6) });
await flush();
expect(worker.requests.map((request) => request.kind === 'links' && Array.from(request.drawn))).toEqual([[0, 1, 2], [3, 4, 5]]);
worker.answer({ kind: 'links', requestId: worker.requests[1].requestId, segments: new Float32Array(12) });
await expect(second).resolves.toHaveLength(12);
// The same list at a different budget is a different graph.
void client.links(3, far, { centre: { x: 1, y: 0, z: 0 }, lengthPc: 10 });
void client.links(3, far, { centre: { x: 1, y: 0, z: 0 }, lengthPc: 20 });
worker.answer({ kind: 'links', requestId: worker.requests[2].requestId, segments: new Float32Array(0) });
await flush();
expect(worker.requests.map((request) => request.kind === 'links' && request.budget?.lengthPc)).toEqual([undefined, undefined, 10, 20]);
// The star field goes on drawing and picking from these lists, so they are copied, not moved.
expect(worker.transferred).not.toContain(near.buffer);
expect(worker.transferred).not.toContain(far.buffer);
client.dispose();
});
it('hands on that the search gave up, along with the answer it did give', async () => {
const { client, worker } = clientWithFake();
const answer = client.route(100, 105, 1.5, 8);
worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: null, gaveUp: true, least: false });
await expect(answer).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: true, least: false });
client.dispose();
});
it('rejects a request the worker failed on, and goes on to the next', async () => {
const { client, worker } = clientWithFake();
const failing = client.route(100, 104, 1.5, 8).catch((error: unknown) => error);
const next = client.links(3, ALL);
worker.answer({ kind: 'failed', requestId: worker.requests[0].requestId, message: 'out of memory' });
expect(((await failing) as Error).message).toBe('out of memory');
await flush();
expect(worker.requests.map((request) => request.kind)).toEqual(['route', 'links']);
worker.answer({ kind: 'links', requestId: worker.requests[1].requestId, segments: new Float32Array(0) });
await expect(next).resolves.toHaveLength(0);
client.dispose();
});
it('answers in place what a worker that failed to load left outstanding, and everything after', async () => {
const { client, worker } = clientWithFake();
const route = client.route(100, 104, 1.5, 8);
const graph = client.links(1.5, Uint32Array.of(0, 1, 3));
worker.fail();
await expect(route).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5).route, neededRangePc: null, gaveUp: false, least: true });
expect(Array.from(await graph)).toEqual([0, 0, 0, 1, 0, 0]);
await expect(client.route(100, 105, 1.5, 8)).resolves.toMatchObject({ route: null });
expect(worker.terminated).toBe(true);
client.dispose();
});
});
@@ -0,0 +1,199 @@
import { answerRouting, RoutingRequest, RoutingResponse } from '../../shared/astro/routing';
import { LinkBudget, Route } from '../../shared/astro/jump-links';
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
import { StarRecord } from '../../shared/models/star.model';
export interface RouteAnswer {
readonly route: Route | null;
readonly neededRangePc: number | null;
/** True when the search at the range asked for gave up rather than ruling a route out. */
readonly gaveUp: boolean;
/** True when the search for a range that would work looked everywhere up to the ceiling. */
readonly least: boolean;
}
/** A request dropped before it was sent, because a newer one of the same kind replaced it. */
export class SupersededRequest extends Error {
constructor() {
super('Superseded by a newer request');
}
}
/** A request made and not yet answered: what was asked, and the promise whoever asked is holding. */
interface Outstanding {
readonly request: RoutingRequest;
readonly promise: Promise<RoutingResponse>;
readonly resolve: (response: RoutingResponse) => void;
readonly reject: (error: Error) => void;
}
function outstanding(request: RoutingRequest): Outstanding {
let resolve!: (response: RoutingResponse) => void;
let reject!: (error: Error) => void;
const promise = new Promise<RoutingResponse>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
return { request, promise, resolve, reject };
}
/**
* Whether two requests ask the same question. A graph is the same when it is for the same range, the
* same budget and the very same list of drawn stars: the star field replaces that list whenever the
* set changes, so one array is one set, and comparing 70 000 indices would cost more than sharing
* could save.
*/
function asksTheSame(a: RoutingRequest, b: RoutingRequest): boolean {
if (a.kind === 'links' || b.kind === 'links') {
return (
a.kind === 'links' &&
b.kind === 'links' &&
a.rangePc === b.rangePc &&
a.drawn === b.drawn &&
a.budget?.lengthPc === b.budget?.lengthPc &&
a.budget?.centre.x === b.budget?.centre.x &&
a.budget?.centre.y === b.budget?.centre.y &&
a.budget?.centre.z === b.budget?.centre.z
);
}
return a.fromId === b.fromId && a.toId === b.toId && a.rangePc === b.rangePc && a.ceilingPc === b.ceilingPc;
}
/** The routing worker, where this environment has one. */
function startRoutingWorker(): Worker | undefined {
return typeof Worker === 'undefined' ? undefined : new Worker(new URL('../../shared/astro/routing.worker', import.meta.url), { type: 'module' });
}
/**
* Asks the route questions of a worker holding its own copy of the catalogue, and hands back
* promises.
*
* The worker answers one request at a time and cannot drop one it has started: a route with no path
* can be seconds of work, and a graph of the drawn stars at 8 pc a few hundred milliseconds. So
* requests are held here and sent one by one, and while one is out, only the latest of each kind
* waits behind it — a newer graph replaces an older one before it is ever built, and the older
* promise is rejected with {@link SupersededRequest}. Routes go ahead of graphs, being quick to ask
* for and asked for by a click. The same question asked again while it is still outstanding shares
* the answer rather than being worked out twice; see `asksTheSame`.
*
* Where there is no worker — the unit tests' DOM has none, and a worker can fail to load or crash —
* the same answers are worked out in place, from the index the scene already holds.
*/
export class RoutingClient {
private worker?: Worker;
private inFlight?: Outstanding;
private readonly waiting: Partial<Record<RoutingRequest['kind'], Outstanding>> = {};
private nextRequestId = 0;
constructor(
stars: readonly StarRecord[],
positions: Float32Array,
private readonly localIndex: StarNeighbourhood,
startWorker: () => Worker | undefined = startRoutingWorker
) {
this.worker = startWorker();
if (!this.worker) {
return;
}
this.worker.addEventListener('message', ({ data }: MessageEvent<RoutingResponse>) => this.settle(data));
// A worker that fails to load, or dies, answers nothing further: everything outstanding, and
// everything asked from here on, is worked out in place instead of waiting for good.
this.worker.addEventListener('error', () => this.abandonWorker());
this.worker.addEventListener('messageerror', () => this.abandonWorker());
// Copies, since the scene goes on using its own; transferred, so the copy is sent and not cloned again.
const ids = Int32Array.from(stars, (star) => star.id);
const copy = positions.slice();
this.worker.postMessage({ kind: 'catalogue', ids, positions: copy }, [ids.buffer, copy.buffer]);
}
route(fromId: number, toId: number, rangePc: number, ceilingPc: number): Promise<RouteAnswer> {
return this.ask({ kind: 'route', requestId: this.nextRequestId++, fromId, toId, rangePc, ceilingPc }).then((response) =>
response.kind === 'route'
? { route: response.route, neededRangePc: response.neededRangePc, gaveUp: response.gaveUp, least: response.least }
: { route: null, neededRangePc: null, gaveUp: false, least: false }
);
}
/**
* Vertex pairs for every link within `rangePc` between two of the `drawn` stars (catalogue
* indices), three floats to an end; only those nearest the budget's centre that fit it, if given.
*/
links(rangePc: number, drawn: Uint32Array, budget?: LinkBudget): Promise<Float32Array> {
return this.ask({ kind: 'links', requestId: this.nextRequestId++, rangePc, drawn, budget }).then((response) =>
response.kind === 'links' ? response.segments : new Float32Array(0)
);
}
dispose(): void {
this.worker?.terminate();
this.worker = undefined;
this.inFlight = undefined;
delete this.waiting.route;
delete this.waiting.links;
}
private ask(request: RoutingRequest): Promise<RoutingResponse> {
if (!this.worker) {
return new Promise((resolve) => resolve(answerRouting(this.localIndex, request)));
}
// Shared rather than replaced: an identical request superseding the one it repeats would reject it,
// and whoever holds that promise would take the rejection for its own question.
const same = [this.inFlight, this.waiting[request.kind]].find((other) => other !== undefined && asksTheSame(other.request, request));
if (same) {
return same.promise;
}
const asked = outstanding(request);
this.waiting[request.kind]?.reject(new SupersededRequest());
this.waiting[request.kind] = asked;
this.sendNext();
return asked.promise;
}
private sendNext(): void {
if (this.inFlight || !this.worker) {
return;
}
const next = this.waiting.route ?? this.waiting.links;
if (!next) {
return;
}
delete this.waiting[next.request.kind];
this.inFlight = next;
// Cloned, never transferred: a graph's `drawn` is the star field's own list, still drawn and
// picked from, and answered in place from should the worker die.
this.worker.postMessage(next.request);
}
private settle(response: RoutingResponse): void {
const answered = this.inFlight;
if (!answered || answered.request.requestId !== response.requestId) {
return;
}
this.inFlight = undefined;
if (response.kind === 'failed') {
answered.reject(new Error(response.message));
} else {
answered.resolve(response);
}
this.sendNext();
}
private abandonWorker(): void {
this.worker?.terminate();
this.worker = undefined;
const stranded = [this.inFlight, this.waiting.route, this.waiting.links];
this.inFlight = undefined;
delete this.waiting.route;
delete this.waiting.links;
for (const request of stranded) {
if (!request) {
continue;
}
try {
request.resolve(answerRouting(this.localIndex, request.request));
} catch (error) {
request.reject(error instanceof Error ? error : new Error(String(error)));
}
}
}
}
@@ -152,13 +152,13 @@ describe('StarFieldRenderer', () => {
it('finds the star under the pointer', () => { it('finds the star under the pointer', () => {
const renderer = new StarFieldRenderer(picked, packPositions(picked)); const renderer = new StarFieldRenderer(picked, packPositions(picked));
// Both Near and Far project to the screen centre; either is a correct hit. // Both Near and Far project to the screen centre; either is a correct hit.
expect([1, 2]).toContain(renderer.pickAt(new THREE.Vector2(0, 0), camera)); expect([1, 2]).toContain(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect));
renderer.dispose(); renderer.dispose();
}); });
it('returns undefined when the pointer is on empty sky', () => { it('returns undefined when the pointer is on empty sky', () => {
const renderer = new StarFieldRenderer(picked, packPositions(picked)); const renderer = new StarFieldRenderer(picked, packPositions(picked));
expect(renderer.pickAt(new THREE.Vector2(-0.9, 0.9), camera)).toBeUndefined(); expect(renderer.pickAt(new THREE.Vector2(-0.9, 0.9), camera, camera.aspect)).toBeUndefined();
renderer.dispose(); renderer.dispose();
}); });
@@ -168,10 +168,29 @@ describe('StarFieldRenderer', () => {
const behind = [star({ id: 7, x: 0, y: 0, z: 10 })]; const behind = [star({ id: 7, x: 0, y: 0, z: 10 })];
const renderer = new StarFieldRenderer(behind, packPositions(behind)); const renderer = new StarFieldRenderer(behind, packPositions(behind));
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined(); expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
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 }),
@@ -182,7 +201,7 @@ describe('StarFieldRenderer', () => {
// Aim at where star 2 projects, and confirm we get it rather than its neighbours. // Aim at where star 2 projects, and confirm we get it rather than its neighbours.
const target = new THREE.Vector3(0, 2, -10).project(camera); const target = new THREE.Vector3(0, 2, -10).project(camera);
expect(renderer.pickAt(new THREE.Vector2(target.x, target.y), camera)).toBe(2); expect(renderer.pickAt(new THREE.Vector2(target.x, target.y), camera, camera.aspect)).toBe(2);
renderer.dispose(); renderer.dispose();
}); });
@@ -195,7 +214,7 @@ describe('StarFieldRenderer', () => {
// Walk outward from the centre until each stops being pickable. // Walk outward from the centre until each stops being pickable.
const reach = (renderer: StarFieldRenderer): number => { const reach = (renderer: StarFieldRenderer): number => {
let offset = 0; let offset = 0;
while (offset < 1 && renderer.pickAt(new THREE.Vector2(0, offset), camera) !== undefined) { while (offset < 1 && renderer.pickAt(new THREE.Vector2(0, offset), camera, camera.aspect) !== undefined) {
offset += 0.001; offset += 0.001;
} }
return offset; return offset;
@@ -212,13 +231,13 @@ describe('StarFieldRenderer', () => {
const faint = [star({ id: 5, x: 0, y: 0, z: -10, magnitude: 15 })]; const faint = [star({ id: 5, x: 0, y: 0, z: -10, magnitude: 15 })];
const renderer = new StarFieldRenderer(faint, packPositions(faint)); const renderer = new StarFieldRenderer(faint, packPositions(faint));
expect(renderer.pickAt(new THREE.Vector2(0, 0.005), camera)).toBe(5); expect(renderer.pickAt(new THREE.Vector2(0, 0.005), camera, camera.aspect)).toBe(5);
renderer.dispose(); renderer.dispose();
}); });
it('finds nothing in an empty field', () => { it('finds nothing in an empty field', () => {
const renderer = new StarFieldRenderer([], new Float32Array(0)); const renderer = new StarFieldRenderer([], new Float32Array(0));
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined(); expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
renderer.dispose(); renderer.dispose();
}); });
}); });
@@ -260,11 +279,6 @@ describe('selectDrawnStars', () => {
expect(drawn).toEqual([0, 2, 3]); expect(drawn).toEqual([0, 2, 3]);
}); });
it('returns catalogue indices in order, so positions can be subset alongside', () => {
const catalogue = Array.from({ length: 100 }, (_, i) => catalogueStar(i, 150, 100 - i));
const drawn = Array.from(selectDrawnStars(catalogue, 10));
expect(drawn).toEqual([...drawn].sort((a, b) => a - b));
});
}); });
describe('StarFieldRenderer render budget', () => { describe('StarFieldRenderer render budget', () => {
@@ -291,3 +305,212 @@ describe('StarFieldRenderer render budget', () => {
renderer.dispose(); renderer.dispose();
}); });
}); });
describe('selectDrawnStars around the view', () => {
/** 200 bright stars 240 pc out, enough to spend any small budget on their own. */
const brightFar = (from: number) => Array.from({ length: 200 }, (_, i) => catalogueStar(from + i, 240, 2));
it('draws a faint star near where the view is centred, however far that is from the Sun', () => {
const faint = catalogueStar(0, 150, 12);
const catalogue = [faint, ...brightFar(1)];
expect(Array.from(selectDrawnStars(catalogue, 20))).not.toContain(0);
expect(Array.from(selectDrawnStars(catalogue, 20, { centre: { x: 150, y: 0, z: 0 } }))).toContain(0);
});
it("keeps the Sun's neighbourhood drawn while the view looks elsewhere", () => {
const catalogue = [catalogueStar(0, 1.3, 11), catalogueStar(1, 150, 13), ...brightFar(2)];
expect(Array.from(selectDrawnStars(catalogue, 20, { centre: { x: 150, y: 0, z: 0 } })).slice(0, 2)).toEqual([1, 0]);
});
it('draws a pinned star wherever it is and however faint', () => {
const catalogue = [catalogueStar(0, 240, 14), ...brightFar(1)];
expect(Array.from(selectDrawnStars(catalogue, 20))).not.toContain(0);
expect(Array.from(selectDrawnStars(catalogue, 20, { pinned: [0] }))).toContain(0);
});
it('spends a budget too small for everything on the pinned stars, then the view, then the Sun, then the brightest', () => {
const catalogue = [catalogueStar(0, 1, 12), catalogueStar(1, 150, 13), catalogueStar(2, 240, 14), ...brightFar(3)];
const focus = { centre: { x: 150, y: 0, z: 0 }, pinned: [2] };
expect(Array.from(selectDrawnStars(catalogue, 4, focus))).toEqual([2, 1, 0, 3]);
expect(Array.from(selectDrawnStars(catalogue, 2, focus))).toEqual([2, 1]);
});
it('keeps the brightest part of a neighbourhood the budget cannot hold whole', () => {
const catalogue = [catalogueStar(0, 150, 9), catalogueStar(1, 151, 4), catalogueStar(2, 152, 11), catalogueStar(3, 153, 6), ...brightFar(4)];
expect(Array.from(selectDrawnStars(catalogue, 2, { centre: { x: 150, y: 0, z: 0 } }))).toEqual([1, 3]);
});
it('draws nothing twice when the view is centred on the Sun or pins a star already near it', () => {
const catalogue = [catalogueStar(0, 1, 12), catalogueStar(1, 2, 13), ...brightFar(2)];
const drawn = Array.from(selectDrawnStars(catalogue, 10, { centre: { x: 0, y: 0, z: 0 }, pinned: [0, 0, 1] }));
expect(new Set(drawn).size).toBe(drawn.length);
expect(drawn).toHaveLength(10);
});
});
describe('selectDrawnStars in view', () => {
/** A star anywhere, with a given apparent magnitude. */
const at = (id: number, x: number, y: number, z: number, magnitude: number) => star({ id, x, y, z, magnitude });
/** What the camera shows, as the scene hands it over. */
const viewOf = (camera: THREE.Camera) => new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
/** Bright stars far in front of `testCamera`, spread across its frame. */
const brightAhead = (from: number, count = 30) => Array.from({ length: count }, (_, i) => at(from + i, (i - count / 2) * 5, 0, -400, 2));
/** Bright stars behind `testCamera`, which only a selection blind to the view would draw. */
const brightBehind = (from: number, count = 30) => Array.from({ length: count }, (_, i) => at(from + i, (i - count / 2) * 5, 0, 400, 2));
it('draws only what is in view, and a pinned star wherever it is', () => {
const ahead = Array.from({ length: 5 }, (_, i) => at(i, i * 10, 0, -240, 12));
const pinnedBehind = at(5, 0, 0, 240, 14);
const catalogue = [...ahead, pinnedBehind, ...brightBehind(6)];
const drawn = Array.from(selectDrawnStars(catalogue, 20, { pinned: [5], view: viewOf(testCamera()) }));
expect(drawn).toEqual([5, 0, 1, 2, 3, 4]);
});
it('reaches a quarter of the frame past its edges, and no further', () => {
// At 100 pc in front of a 55° camera the frame's half-height is 52 pc: 1.2 of it is 62.5 pc, 1.3 is 67.7.
const halfHeight = 100 * Math.tan((55 * Math.PI) / 360);
const catalogue = [at(0, 0, 1.2 * halfHeight, -100, 12), at(1, 0, 1.3 * halfHeight, -100, 12), ...brightBehind(2)];
const drawn = Array.from(selectDrawnStars(catalogue, 20, { view: viewOf(testCamera()) }));
expect(drawn).toEqual([0]);
});
it("draws the neighbourhood of the view's centre ahead of brighter stars, but only the part in view", () => {
const camera = new THREE.PerspectiveCamera(55, 16 / 9, 0.01, 5000);
camera.position.set(0, 0, -140);
camera.lookAt(0, 0, -1000);
camera.updateMatrixWorld(true);
const memberAhead = at(0, 0, 0, -160, 14);
const memberBehind = at(1, 0, 0, -130, 14);
const catalogue = [memberAhead, memberBehind, ...Array.from({ length: 30 }, (_, i) => at(2 + i, (i - 15) * 5, 0, -600, 2))];
const drawn = Array.from(selectDrawnStars(catalogue, 20, { centre: { x: 0, y: 0, z: -150 }, view: viewOf(camera) }));
expect(drawn[0]).toBe(0);
expect(drawn).not.toContain(1);
});
it('draws the planet hosts in view first after the pinned stars, and not those out of view', () => {
const hostAhead = at(0, 0, 0, -240, 14);
const hostBehind = at(1, 0, 0, 240, 14);
const nearSun = at(2, 0, 0, -10, 13);
const catalogue = [hostAhead, hostBehind, nearSun, ...brightAhead(3)];
const hosts = Uint8Array.from(catalogue, (_, index) => (index < 2 ? 1 : 0));
const drawn = Array.from(selectDrawnStars(catalogue, 3, { hosts, view: viewOf(testCamera()) }));
expect(drawn).toEqual([0, 2, 3]);
});
it('frames a plan view as a box, however deep: behind the camera included', () => {
const plan = new THREE.OrthographicCamera(-10, 10, 10, -10, -5000, 5000);
plan.position.set(0, 0, 0);
plan.lookAt(0, 0, -1);
plan.updateMatrixWorld(true);
const catalogue = [at(0, 0, 0, 50, 12), at(1, 12, 0, -50, 12), at(2, 13, 0, -50, 12), ...Array.from({ length: 30 }, (_, i) => at(3 + i, 500, i, 0, 2))];
const drawn = Array.from(selectDrawnStars(catalogue, 20, { view: viewOf(plan) }));
expect(drawn).toEqual([0, 1]);
});
});
describe('StarFieldRenderer refocus', () => {
const camera = testCamera();
/** A faint star straight ahead, 150 pc out, among bright ones well off to the side. */
const faintAhead = star({ id: 77, x: 0, y: 0, z: -150, magnitude: 13, colorIndex: 1.9 });
const catalogue = [faintAhead, ...Array.from({ length: 50 }, (_, i) => star({ id: 100 + i, x: 60, y: i, z: -40, magnitude: 1, colorIndex: -0.3 + i * 0.04 }))];
const positions = packPositions(catalogue);
it('draws and picks a faint star once the view is centred near it', () => {
const renderer = new StarFieldRenderer(catalogue, positions, 10);
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
renderer.refocus({ centre: { x: 0, y: 0, z: -140 } });
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBe(77);
expect((renderer.object.geometry as THREE.InstancedBufferGeometry).instanceCount).toBe(renderer.drawnCount);
renderer.dispose();
});
it('draws a pinned star, and passes over an index past the end of the catalogue', () => {
const renderer = new StarFieldRenderer(catalogue, positions, 10);
renderer.refocus({ pinned: [123456, 0] });
const drawnIds = Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i));
expect(drawnIds).toContain(77);
expect(renderer.drawnCount).toBe(10);
renderer.dispose();
});
it('gives each drawn star its own colour and size, wherever the refocus put it', () => {
const renderer = new StarFieldRenderer(catalogue, positions, 10);
renderer.refocus({ centre: { x: 0, y: 0, z: -140 }, pinned: [21] });
const { colorAttribute, sizeAttribute } = renderer as unknown as { colorAttribute: THREE.InstancedBufferAttribute; sizeAttribute: THREE.InstancedBufferAttribute };
for (let instance = 0; instance < renderer.drawnCount; instance++) {
const drawnStar = catalogue.find((candidate) => candidate.id === renderer.starIdAt(instance))!;
const expected = colorIndexToRgb(drawnStar.colorIndex, drawnStar.spectralType);
expect(colorAttribute.getX(instance)).toBeCloseTo(expected.r, 5);
expect(colorAttribute.getZ(instance)).toBeCloseTo(expected.b, 5);
expect(sizeAttribute.getX(instance)).toBeGreaterThan(0);
}
const faintSlot = Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i)).indexOf(77);
const brightSlot = Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i)).indexOf(120);
expect(sizeAttribute.getX(brightSlot)).toBeGreaterThan(sizeAttribute.getX(faintSlot));
renderer.dispose();
});
it('leaves the buffers alone when the drawn set has not changed, and rewrites them when it has', () => {
const renderer = new StarFieldRenderer(catalogue, positions, 10);
const { positionAttribute } = renderer as unknown as { positionAttribute: THREE.InstancedBufferAttribute };
const version = positionAttribute.version;
renderer.refocus({ centre: { x: 0, y: 0, z: 0 } });
expect(positionAttribute.version).toBe(version);
renderer.refocus({ centre: { x: 0, y: 0, z: -140 } });
expect(positionAttribute.version).toBeGreaterThan(version);
renderer.dispose();
});
it('drops a star from the drawn set, and from picking, once the view has moved away from it', () => {
// The subtle failure this guards: buffers rewritten for a new selection while picking still
// reads the old one would leave clickable ghosts where nothing is drawn.
const renderer = new StarFieldRenderer(catalogue, positions, 10);
renderer.refocus({ centre: { x: 0, y: 0, z: -140 } });
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBe(77);
renderer.refocus({ centre: { x: 0, y: 0, z: 0 } });
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
expect(Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i))).not.toContain(77);
renderer.dispose();
});
it('drops a star from the drawn set, and from picking, once the camera has turned away from it', () => {
const renderer = new StarFieldRenderer(catalogue, positions, 10);
const view = (from: THREE.Camera) => new THREE.Matrix4().multiplyMatrices(from.projectionMatrix, from.matrixWorldInverse);
renderer.refocus({ centre: { x: 0, y: 0, z: -140 }, view: view(camera) });
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBe(77);
const turned = testCamera();
turned.lookAt(0, 0, 1);
turned.updateMatrixWorld(true);
renderer.refocus({ centre: { x: 0, y: 0, z: -140 }, view: view(turned) });
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
expect(Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i))).not.toContain(77);
renderer.dispose();
});
});
@@ -1,23 +1,16 @@
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
import { instancedBufferAttribute, smoothstep, uv, vec2 } from 'three/tsl'; import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
import { BrightnessIndex, brightnessIndex, Positioned } from '../../shared/astro/brightest';
import { spectralTypeToColorIndex } from '../../shared/astro/spectral'; import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
import { SceneCamera } from '../../core/engine/engine.service';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size';
/** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */ /** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */
const MIN_POINT_SIZE = 1.5; const MIN_POINT_SIZE = 1.5;
const MAX_POINT_SIZE = 6; const MAX_POINT_SIZE = 6;
/**
* Star size is expressed in pixels for readability, but the material works in angular size, so
* the two are related through the scene's vertical field of view and a reference viewport.
* Because the size is angular, a star keeps the same share of the screen at any window size —
* these pixel figures are exact only at this reference height.
*/
const REFERENCE_VIEWPORT_HEIGHT_PX = 900;
const REFERENCE_FOV_DEGREES = 55;
const PIXELS_TO_ANGULAR_SIZE =
(2 * Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 180 / 2)) / REFERENCE_VIEWPORT_HEIGHT_PX;
/** /**
* Extra click forgiveness added to a star's drawn radius, in NDC — roughly 4 px on the * Extra click forgiveness added to a star's drawn radius, in NDC — roughly 4 px on the
@@ -33,25 +26,25 @@ const PICK_NDC_SLOP = 0.01;
/** /**
* How many stars the field draws at once, however many the catalogue holds. * How many stars the field draws at once, however many the catalogue holds.
* *
* The catalogue reaches as far as its parallaxes do — 68388 stars at 250 pc — but drawing all of * The *data* is the catalogue and the *drawing* is a budget, and the two are allowed to differ:
* them is a cost paid every frame by every machine, and most of that cost buys 1.5-pixel dots. * everything still exists for search, for flying to, and for hosting planets. Which stars fill
* So the *data* is the catalogue and the *drawing* is a budget, and the two are allowed to * the budget follows the view; see {@link selectDrawnStars}.
* differ. Everything still exists for search, for flying to, and for hosting planets.
* *
* Currently set to the whole catalogue, which is what a GPU should be asked to do — this is one * The number is set by what the field looks like, before what it costs. The catalogue is
* instanced draw call, and a discrete card will not notice it. The budget still exists because * 423 651 stars since Gaia, and drawn whole the opening view is a grey wash: the additive
* the catalogue is meant to grow past what any machine should draw at once: Gaia alone could * blending of that many 1.5-pixel dots buries the labels, the rings on the planet hosts and the
* contribute a million stars, and at that point the selection below is what keeps the field * grid. At 150 000 the wash has begun; at this budget the view reads. Measured at 1920 × 1080 on
* legible rather than a grey wash. * a Ryzen 7700X, the cost argues the same way. An RTX 4080 draws the whole catalogue in the same
* * 6.1 ms a frame as this budget, so a discrete card does not notice. The processor's own
* Machines without a GPU do feel it. A software rasterizer measured here lost about a third of * two-core Radeon, standing in for an entry-level laptop, pays about 4 ms a frame for every
* its frame rate per 12000 stars drawn; if that matters for a deployment, this is the one number * 100 000 stars: 112 frames a second at this budget, 44 at the whole catalogue, and the same
* to turn down. * again under the WebGL2 fallback.
*/ */
export const STAR_RENDER_BUDGET = 68388; export const STAR_RENDER_BUDGET = 70_000;
/** /**
* Radius (parsecs) inside which every star is drawn regardless of brightness. * Radius (parsecs) around the Sun, and around wherever the view is centred, inside which every
* star in view is drawn regardless of brightness.
* *
* A pure brightness cut would be defensible — apparent magnitude is exactly "how visible this * A pure brightness cut would be defensible — apparent magnitude is exactly "how visible this
* is" — but it would drop the solar neighbourhood, because the nearest stars are overwhelmingly * is" — but it would drop the solar neighbourhood, because the nearest stars are overwhelmingly
@@ -59,11 +52,50 @@ export const STAR_RENDER_BUDGET = 68388;
* and the ones that hold the nearby planets, so the neighbourhood is kept whole and the budget * and the ones that hold the nearby planets, so the neighbourhood is kept whole and the budget
* is spent on the brightest of everything beyond it. * is spent on the brightest of everything beyond it.
* *
* Kept deliberately small against the catalogue's 250 pc reach. The guaranteed core occupies a * The same holds wherever the view is looking. Before the drawn set followed the view, a region
* thousandth of that volume, so a generous radius spends most of the budget inside it and draws * 150 pc out drew 49 of the 442 stars within this radius of it, and a route plotted there ran
* a dense knot surrounded by nothing — which is a worse picture than the smaller catalogue was. * through waypoints nobody could see or click: Sol to Almach at 8 pc passed 19 stars and drew 6.
*
* Kept deliberately small against the catalogue's reach. Around the Sun it holds 3 654 stars;
* a generous radius spends most of the budget inside it and draws a dense knot surrounded by
* nothing.
*/ */
export const ALWAYS_DRAWN_RADIUS_PC = 25; export const FOCUS_RADIUS_PC = 25;
/**
* How far past the edges of the frame the drawn stars reach, as a share of the frame's half-width
* and half-height: 5° beyond the top and bottom at the 50° field of view, 6° beyond each side.
*
* The drawn set is chosen for a camera pose and kept until the view has turned or moved half this
* far, so the margin is what is on screen by the time it is chosen again. Wider stays whole
* through faster turns but spends the budget off screen: at 30 pc from the Sun, where the budget
* binds, 0.25 leaves 52 000 of the 70 000 on screen and 0.5 only 44 000.
*/
export const VIEW_MARGIN = 0.25;
/** What, besides the brightest stars, the field should be sure to draw. */
export interface DrawFocus {
/** Where the view is centred. Its neighbourhood is drawn whole, like the Sun's. */
readonly centre?: Positioned;
/**
* Catalogue indices drawn wherever they are and however faint: the selected star, the stars
* of a plotted route. Anything the map points at has to be there to be pointed at.
*/
readonly pinned?: readonly number[];
/**
* 1 for each catalogue index with known planets. Drawn next after the pinned stars, however
* faint: each carries a ring, and a ring around a star that is not drawn circles nothing that
* can be clicked.
*/
readonly hosts?: Uint8Array;
/**
* The camera's projection times its view matrix. Only stars inside its frame, widened by
* {@link VIEW_MARGIN}, are drawn, pinned stars aside; without it, the whole sky is in view.
*/
readonly view?: THREE.Matrix4;
}
const SUN: Positioned = { x: 0, y: 0, z: 0 };
const COLD_STAR_COLOR = new THREE.Color(0.65, 0.75, 1.0); const COLD_STAR_COLOR = new THREE.Color(0.65, 0.75, 1.0);
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0); const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
@@ -109,7 +141,99 @@ function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometr
} }
/** /**
* Builds the galaxy-scale star field as instanced camera-facing billboards, one per HYG star, * Reads a render budget override off the page URL (`?stars=20000`), falling back to the default.
*
* Two uses, one real and one incidental. The real one is a deployment or a machine that cannot
* draw the whole catalogue — a number in a URL beats a rebuild. The incidental one is the
* end-to-end suite, which runs against a software rasterizer whose frame rate is two orders of
* magnitude below a real GPU's: those tests are checking navigation and state, and making them
* wait on a rasterizer measures nothing about the app.
*/
export function starRenderBudgetFromUrl(search: string, fallback = STAR_RENDER_BUDGET): number {
const requested = Number(new URLSearchParams(search).get('stars'));
return Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : fallback;
}
/**
* Chooses which stars to draw when the catalogue is larger than the budget. In order, until the
* budget is spent: the pinned stars wherever they are, then of the stars in view, the planet
* hosts, everything within {@link FOCUS_RADIUS_PC} of where the view is centred, everything within
* it of the Sun, and the brightest of the rest. Each tier is taken brightest first, so a budget too
* small to hold one whole keeps its most visible part.
*
* Returns indices into the original list, in the order they were chosen. `index` is the
* catalogue's brightness index, passed in when the caller already has it rather than sorted again
* on every call.
*/
export function selectDrawnStars(
stars: readonly StarRecord[],
budget = STAR_RENDER_BUDGET,
focus: DrawFocus = {},
index: BrightnessIndex = brightnessIndex(stars)
): Uint32Array {
if (stars.length <= budget) {
return Uint32Array.from(stars.keys());
}
// One walk of the brightness order, reading positions laid out in that order, sorts each tier
// brightest first as it goes: 2.4-3.1 ms on the real catalogue in Node, against 6.4-8.5 ms
// gathering both neighbourhoods in catalogue order and sorting them. Of the stars in no earlier
// tier only the first `budget` in view can ever be taken, so past those it looks for the tiers.
const { order, positions } = index;
const radiusSq = FOCUS_RADIUS_PC * FOCUS_RADIUS_PC;
const centre = focus.centre ?? SUN;
const view = focus.view?.elements;
const reachScale = 1 + VIEW_MARGIN;
const hosts: number[] = [];
const nearCentre: number[] = [];
const nearSun: number[] = [];
const rest: number[] = [];
for (let at = 0; at < order.length; at++) {
const x = positions[at * 3];
const y = positions[at * 3 + 1];
const z = positions[at * 3 + 2];
const dx = x - centre.x;
const dy = y - centre.y;
const dz = z - centre.z;
const isHost = focus.hosts?.[order[at]] === 1;
const inCentre = dx * dx + dy * dy + dz * dz <= radiusSq;
const inSun = x * x + y * y + z * z <= radiusSq;
if (!isHost && !inCentre && !inSun && rest.length >= budget) {
continue;
}
if (view) {
// In clip space: in frame when |x| and |y| are within w, widened by the margin. Behind a
// perspective camera w is negative, so nothing there passes; an orthographic camera's w is 1.
const reach = (view[3] * x + view[7] * y + view[11] * z + view[15]) * reachScale;
if (Math.abs(view[0] * x + view[4] * y + view[8] * z + view[12]) > reach || Math.abs(view[1] * x + view[5] * y + view[9] * z + view[13]) > reach) {
continue;
}
}
(isHost ? hosts : inCentre ? nearCentre : inSun ? nearSun : rest).push(order[at]);
}
const chosen = new Uint8Array(stars.length);
const selected: number[] = [];
const take = (index: number): void => {
if (!chosen[index] && selected.length < budget) {
chosen[index] = 1;
selected.push(index);
}
};
for (const pinned of focus.pinned ?? []) {
if (pinned >= 0 && pinned < stars.length) {
take(pinned);
}
}
hosts.forEach(take);
nearCentre.forEach(take);
nearSun.forEach(take);
rest.forEach(take);
return Uint32Array.from(selected);
}
/**
* Builds the galaxy-scale star field as instanced camera-facing billboards, one per drawn star,
* coloured by spectral index and sized by magnitude. * coloured by spectral index and sized by magnitude.
* *
* **Why billboards and not `THREE.Points`.** Point primitives are capped at a single pixel on * **Why billboards and not `THREE.Points`.** Point primitives are capped at a single pixel on
@@ -124,95 +248,81 @@ function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometr
* Sizes are angular (`sizeAttenuation = false`), so a star holds the same apparent size however * Sizes are angular (`sizeAttenuation = false`), so a star holds the same apparent size however
* close the camera gets. That is deliberate and physically right: real stars are unresolvable * close the camera gets. That is deliberate and physically right: real stars are unresolvable
* point sources, and their apparent size on screen is a function of brightness, not distance. * point sources, and their apparent size on screen is a function of brightness, not distance.
*/
/**
* Chooses which stars to draw when the catalogue is larger than the budget: everything inside
* the neighbourhood radius, then the brightest of the rest until the budget is spent.
* *
* Returns indices into the original list, so the caller can subset the positions that go with * The instance buffers hold the budget, not the catalogue, and are rewritten in place when
* them. Returns them in catalogue order rather than in selection order, purely so the drawn set * {@link refocus} changes which stars fill it.
* is stable and inspectable.
*/ */
/**
* Reads a render budget override off the page URL (`?stars=20000`), falling back to the default.
*
* Two uses, one real and one incidental. The real one is a deployment or a machine that cannot
* draw the whole catalogue — a number in a URL beats a rebuild. The incidental one is the
* end-to-end suite, which runs against a software rasterizer whose frame rate is two orders of
* magnitude below a real GPU's: those tests are checking navigation and state, and making them
* wait on a rasterizer measures nothing about the app.
*/
export function starRenderBudgetFromUrl(search: string, fallback = STAR_RENDER_BUDGET): number {
const requested = Number(new URLSearchParams(search).get('stars'));
return Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : fallback;
}
export function selectDrawnStars(stars: readonly StarRecord[], budget = STAR_RENDER_BUDGET): Uint32Array {
if (stars.length <= budget) {
return Uint32Array.from(stars.keys());
}
const near: number[] = [];
const far: number[] = [];
stars.forEach((star, index) => {
(Math.hypot(star.x, star.y, star.z) <= ALWAYS_DRAWN_RADIUS_PC ? near : far).push(index);
});
far.sort((a, b) => stars[a].magnitude - stars[b].magnitude);
const selected = near.concat(far.slice(0, Math.max(0, budget - near.length)));
selected.sort((a, b) => a - b);
return Uint32Array.from(selected);
}
export class StarFieldRenderer { export class StarFieldRenderer {
readonly object: THREE.Mesh; readonly object: THREE.Mesh;
/** How many of the catalogue's stars this field actually draws. */
readonly drawnCount: number; /** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */
private readonly perspective = uniform(1);
private readonly orthographicScale = uniform(float(0));
private readonly geometry: THREE.InstancedBufferGeometry; private readonly geometry: THREE.InstancedBufferGeometry;
private readonly material: THREE.SpriteNodeMaterial; private readonly material: THREE.SpriteNodeMaterial;
/** The subset of the catalogue that is drawn, and so the only set that can be clicked. */ private readonly budget: number;
private readonly stars: readonly StarRecord[]; private readonly brightness: BrightnessIndex;
/** Angular diameter per drawn star, in the same order as `stars` — reused for picking. */ /**
private readonly angularSizes: Float32Array; * Colour and angular size of every star in the catalogue, worked out once: a refocus then only
* copies them into the instances, 0.7 ms for the budget rather than 5.6 ms computing them again.
*/
private readonly catalogueColors: Float32Array;
private readonly catalogueSizes: Float32Array;
constructor(catalogue: readonly StarRecord[], cataloguePositions: Float32Array, budget = STAR_RENDER_BUDGET) { /** Per-instance data, `budget` long; the first `drawnCount` entries are live. */
const drawn = selectDrawnStars(catalogue, budget); private readonly positionAttribute: THREE.InstancedBufferAttribute;
this.stars = drawn.length === catalogue.length ? catalogue : Array.from(drawn, (index) => catalogue[index]); private readonly colorAttribute: THREE.InstancedBufferAttribute;
this.drawnCount = this.stars.length; private readonly sizeAttribute: THREE.InstancedBufferAttribute;
/** Catalogue index behind each live instance: the set that is drawn, and so the only set that can be clicked. */
private drawn: Uint32Array = new Uint32Array(0);
const stars = this.stars; constructor(
this.geometry = createQuadGeometry(stars.length); private readonly catalogue: readonly StarRecord[],
private readonly cataloguePositions: Float32Array,
budget = STAR_RENDER_BUDGET,
brightness?: BrightnessIndex
) {
this.budget = budget;
this.brightness = brightness ?? brightnessIndex(catalogue);
const capacity = Math.min(budget, catalogue.length);
this.geometry = createQuadGeometry(0);
const colors = new Float32Array(stars.length * 3); this.positionAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
this.angularSizes = new Float32Array(stars.length); this.colorAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
// Repacked only when the drawn set is a subset; otherwise the ETL's buffer is used as-is. this.sizeAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity), 1);
const positions =
drawn.length === catalogue.length
? cataloguePositions
: Float32Array.from({ length: drawn.length * 3 }, (_, i) => cataloguePositions[drawn[(i / 3) | 0] * 3 + (i % 3)]);
stars.forEach((star, index) => { this.catalogueColors = new Float32Array(catalogue.length * 3);
this.catalogueSizes = new Float32Array(catalogue.length);
catalogue.forEach((star, index) => {
const color = colorIndexToRgb(star.colorIndex, star.spectralType); const color = colorIndexToRgb(star.colorIndex, star.spectralType);
colors[index * 3] = color.r; this.catalogueColors[index * 3] = color.r;
colors[index * 3 + 1] = color.g; this.catalogueColors[index * 3 + 1] = color.g;
colors[index * 3 + 2] = color.b; this.catalogueColors[index * 3 + 2] = color.b;
this.angularSizes[index] = magnitudeToPointSize(star.magnitude) * PIXELS_TO_ANGULAR_SIZE; this.catalogueSizes[index] = magnitudeToPointSize(star.magnitude) * PIXELS_TO_ANGULAR_SIZE;
}); });
const positionAttribute = new THREE.InstancedBufferAttribute(positions, 3);
const colorAttribute = new THREE.InstancedBufferAttribute(colors, 3);
const sizeAttribute = new THREE.InstancedBufferAttribute(this.angularSizes, 1);
this.material = new THREE.SpriteNodeMaterial({ this.material = new THREE.SpriteNodeMaterial({
transparent: true, transparent: true,
depthWrite: false, depthWrite: false,
blending: THREE.AdditiveBlending blending: THREE.AdditiveBlending
}); });
this.material.sizeAttenuation = false; // The compensation that turns an angular size into a world size is done here rather than by
this.material.positionNode = instancedBufferAttribute(positionAttribute, 'vec3'); // `sizeAttenuation: false`, which three.js applies only when it is compiling against a
this.material.scaleNode = instancedBufferAttribute(sizeAttribute, 'float'); // perspective camera (SpriteNodeMaterial.js: `camera.isPerspectiveCamera && sizeAttenuation
this.material.colorNode = instancedBufferAttribute(colorAttribute, 'vec3'); // === false`). Under an orthographic one it is silently skipped and every star collapses to
// a thousandth of a parsec — invisible. Doing the same arithmetic in the node graph, behind
// a uniform, lets one material serve both cameras without being recompiled between them.
this.material.sizeAttenuation = true;
const position = instancedBufferAttribute<'vec3'>(this.positionAttribute, 'vec3');
const angularSize = instancedBufferAttribute<'float'>(this.sizeAttribute, 'float');
this.material.positionNode = position;
// Perspective: a star's world size is its angular size times how far away it is, which is
// exactly what the built-in does. Orthographic: distance does not set apparent size at all,
// the frustum does, so the same angular size is scaled by the frustum instead.
const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate();
this.material.scaleNode = angularSize.mul(mix(this.orthographicScale, viewDepth, this.perspective));
this.material.colorNode = instancedBufferAttribute<'vec3'>(this.colorAttribute, 'vec3');
// Soft radial falloff so each star is a small bright core inside a halo, rather than a // Soft radial falloff so each star is a small bright core inside a halo, rather than a
// hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre. // hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre.
const radius = uv().sub(vec2(0.5)).length(); const radius = uv().sub(vec2(0.5)).length();
@@ -222,11 +332,69 @@ export class StarFieldRenderer {
// The quad's own bounds sit at the origin and say nothing about where the instances are, // The quad's own bounds sit at the origin and say nothing about where the instances are,
// so leaving culling on would drop the whole field whenever the origin left the frustum. // so leaving culling on would drop the whole field whenever the origin left the frustum.
this.object.frustumCulled = false; this.object.frustumCulled = false;
this.refocus({});
} }
/** Looks up the HYG star id for a given instance index. */ /** How many of the catalogue's stars this field is drawing. */
get drawnCount(): number {
return this.drawn.length;
}
/**
* The catalogue indices being drawn. Replaced by a refocus that changes them, never changed in
* place, so the same array means the same stars.
*/
get drawnStars(): Uint32Array {
return this.drawn;
}
/**
* Chooses the drawn stars again for where the view now is, and rewrites the instance buffers
* with them. See {@link selectDrawnStars}.
*/
refocus(focus: DrawFocus): void {
const drawn = selectDrawnStars(this.catalogue, this.budget, focus, this.brightness);
// The same stars in the same instances: the buffers already hold them, and a rewrite would
// upload 2 MB to the GPU for nothing — which a pan across empty space would do every pass.
if (drawn.length === this.drawn.length && drawn.every((index, instance) => index === this.drawn[instance])) {
return;
}
this.drawn = drawn;
const positions = this.positionAttribute.array as Float32Array;
const colors = this.colorAttribute.array as Float32Array;
const sizes = this.sizeAttribute.array as Float32Array;
this.drawn.forEach((catalogueIndex, instance) => {
for (let axis = 0; axis < 3; axis++) {
positions[instance * 3 + axis] = this.cataloguePositions[catalogueIndex * 3 + axis];
colors[instance * 3 + axis] = this.catalogueColors[catalogueIndex * 3 + axis];
}
sizes[instance] = this.catalogueSizes[catalogueIndex];
});
this.geometry.instanceCount = this.drawn.length;
this.positionAttribute.needsUpdate = true;
this.colorAttribute.needsUpdate = true;
this.sizeAttribute.needsUpdate = true;
}
/**
* Tells the field which projection it is being drawn under.
*
* `halfHeightWorld` is half the orthographic frustum's height in world units; `null` means a
* perspective camera, where a star's distance sets its apparent size on its own.
*/
setProjection(halfHeightWorld: number | null): void {
this.perspective.value = halfHeightWorld === null ? 1 : 0;
// The world size that subtends the same share of the viewport an angular size would under
// the reference field of view: `angular * halfHeight / tan(fov/2)`.
this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360);
}
/** Looks up the star id for a given instance index. */
starIdAt(instanceIndex: number): number | undefined { starIdAt(instanceIndex: number): number | undefined {
return this.stars[instanceIndex]?.id; return instanceIndex >= 0 && instanceIndex < this.drawn.length ? this.catalogue[this.drawn[instanceIndex]].id : undefined;
} }
/** /**
@@ -238,27 +406,45 @@ 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: THREE.PerspectiveCamera): number | undefined { pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined {
const tanHalfFov = Math.tan((camera.fov * Math.PI) / 360); // What a unit of angular size is worth on screen. Under perspective the field of view sets
// it. Under an orthographic camera the frustum does — but `setProjection` sized the sprite
// as `angular * halfHeight / tan(REFERENCE_FOV/2)` in the first place, so dividing back out
// by that same half-height leaves the reference field of view and nothing else. Both cases
// are therefore one formula over a different angle.
const perspective = (camera as THREE.PerspectiveCamera).isPerspectiveCamera;
const tanHalfFov = Math.tan(((perspective ? (camera as THREE.PerspectiveCamera).fov : REFERENCE_FOV_DEGREES) * Math.PI) / 360);
const projected = new THREE.Vector3(); const projected = new THREE.Vector3();
const positions = this.positionAttribute.array as Float32Array;
const sizes = this.sizeAttribute.array as Float32Array;
let bestIndex: number | undefined; let bestIndex: number | undefined;
let bestScore = Infinity; let bestScore = Infinity;
for (let index = 0; index < this.stars.length; index++) { for (let index = 0; index < this.drawn.length; index++) {
const star = this.stars[index]; projected.set(positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]).project(camera);
projected.set(star.x, star.y, star.z).project(camera);
// Outside the depth range means behind the camera or beyond the far plane; `project` // Outside the depth range means behind the camera or beyond the far plane; `project`
// mirrors points behind the camera onto the screen, so this guard is load-bearing. // mirrors points behind the camera onto the screen, so this guard is load-bearing.
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 * this.angularSizes[index]) / tanHalfFov + PICK_NDC_SLOP; const drawnRadius = (0.5 * sizes[index]) / tanHalfFov;
const dx = (projected.x - pointerNdc.x) * camera.aspect; // 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 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;
@@ -268,7 +454,7 @@ export class StarFieldRenderer {
} }
} }
return bestIndex === undefined ? undefined : this.stars[bestIndex].id; return bestIndex === undefined ? undefined : this.starIdAt(bestIndex);
} }
dispose(): void { dispose(): void {
@@ -1,6 +1,8 @@
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
import { StarLabelOverlay } from './star-label-overlay'; import { StarLabelOverlay } from './star-label-overlay';
describe('StarLabelOverlay', () => { describe('StarLabelOverlay', () => {
@@ -77,6 +79,66 @@ describe('StarLabelOverlay', () => {
expect(labels()).toHaveLength(0); expect(labels()).toHaveLength(0);
}); });
it('hangs a label on the side it is told to, and can move it across', () => {
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0, side: 'left' }]);
const object = scene.children[0] as CSS2DObject;
expect(labels()[0].classList.contains('map-label--left')).toBe(true);
expect(object.center.x).toBe(1);
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0, side: 'right' }]);
expect(labels()[0].classList.contains('map-label--left')).toBe(false);
expect(object.center.x).toBe(0);
});
it('brackets one selected point with the mark, moves it, and clears it', () => {
overlay.setSelection({ x: 1, y: 2, z: 3 });
overlay.setSelection({ x: 4, y: 5, z: 6 });
overlay.render(camera);
const marks = overlay.domElement.querySelectorAll('.map-select');
expect(marks).toHaveLength(1);
expect((scene.children[0] as THREE.Object3D).position.toArray()).toEqual([4, 5, 6]);
overlay.setSelection(null);
overlay.render(camera);
expect(overlay.domElement.querySelectorAll('.map-select')).toHaveLength(0);
expect(scene.children).toHaveLength(0);
});
it('says a neighbour more quietly than a body of this system', () => {
overlay.update([
{ id: 'earth', name: 'Earth', kind: 'Planet', x: 1, y: 0, z: 0 },
{ id: 'neighbour:1', name: 'Sirius', kind: '2.64 pc', tone: 'ghost', selectStarId: 1, x: 0, y: 1, z: 0 }
]);
const [body, ghost] = labels();
expect(body.classList.contains('map-label--ghost')).toBe(false);
expect(ghost.classList.contains('map-label--ghost')).toBe(true);
expect(ghost.querySelector('.map-label-kind')?.textContent).toBe('2.64 pc');
});
it('makes a label that offers a star a button, and hands back the star it names', () => {
const chosen: number[] = [];
overlay = new StarLabelOverlay(scene, (starId) => chosen.push(starId));
overlay.setSize(800, 600);
overlay.update([{ id: 'neighbour:42', name: 'Sirius', kind: '2.64 pc', tone: 'ghost', selectStarId: 42, x: 1, y: 0, z: 0 }]);
const ghost = labels()[0];
expect(ghost.tagName).toBe('BUTTON');
// Its two lines are adjacent spans, so without this it is announced as "Sirius2.64 pc".
expect(ghost.getAttribute('aria-label')).toBe('Go to Sirius, 2.64 pc away');
ghost.click();
expect(chosen).toEqual([42]);
});
it('leaves a label that offers nothing untouchable, so the scene behind it stays clickable', () => {
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 }]);
const label = labels()[0];
expect(label.tagName).toBe('DIV');
expect(label.classList.contains('map-label--select')).toBe(false);
});
it('leaves nothing behind when disposed', () => { it('leaves nothing behind when disposed', () => {
overlay.update([ overlay.update([
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 }, { id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
@@ -15,11 +15,49 @@ export interface LabeledPoint {
* is called. * is called.
*/ */
kind?: string; kind?: string;
/**
* Which side of the point the text hangs on. Right is the default; left is for a point close
* to the right edge of the view, or one whose right-hand text would run into a neighbour's.
*/
side?: LabelSide;
/**
* `ghost` is the quieter voice: a star outside the system the camera is in, named so its
* direction can be read without leaving. Dimmer, and it can be selected.
*/
tone?: LabelTone;
/**
* The star this label offers to fly to. Present makes the label a real button — focusable,
* clickable, and the only labels the pointer can reach at all. Whether a given id is
* selectable never changes between updates, so the element it needs is settled at creation.
*/
selectStarId?: number;
x: number; x: number;
y: number; y: number;
z: number; z: number;
} }
export type LabelSide = 'left' | 'right';
export type LabelTone = 'normal' | 'ghost';
/** Where the selection mark sits, in the same scene units as the labels. */
export interface SelectionPoint {
x: number;
y: number;
z: number;
}
function classesFor(point: Pick<LabeledPoint, 'side' | 'tone' | 'selectStarId'>): string {
return [
'map-label',
point.side === 'left' ? 'map-label--left' : '',
point.tone === 'ghost' ? 'map-label--ghost' : '',
point.selectStarId === undefined ? '' : 'map-label--select',
'whitespace-nowrap font-body'
]
.filter(Boolean)
.join(' ');
}
/** /**
* Renders DOM-based (CSS2D) name labels anchored to 3D star positions. Labels are added as * Renders DOM-based (CSS2D) name labels anchored to 3D star positions. Labels are added as
* children of the main scene (so `CSS2DRenderer` can project them with the same camera) and * children of the main scene (so `CSS2DRenderer` can project them with the same camera) and
@@ -31,8 +69,13 @@ export class StarLabelOverlay {
private readonly cssRenderer = new CSS2DRenderer(); private readonly cssRenderer = new CSS2DRenderer();
private readonly labelObjects = new Map<number | string, CSS2DObject>(); private readonly labelObjects = new Map<number | string, CSS2DObject>();
private selection?: CSS2DObject;
constructor(private readonly scene: THREE.Scene) { constructor(
private readonly scene: THREE.Scene,
/** Called with the star a selectable label names, when it is clicked or keyed. */
private readonly onSelectStar?: (starId: number) => void
) {
this.cssRenderer.domElement.classList.add('star-label-layer'); this.cssRenderer.domElement.classList.add('star-label-layer');
this.domElement = this.cssRenderer.domElement; this.domElement = this.cssRenderer.domElement;
} }
@@ -61,12 +104,48 @@ export class StarLabelOverlay {
const existing = this.labelObjects.get(point.id); const existing = this.labelObjects.get(point.id);
if (existing) { if (existing) {
existing.position.set(point.x, point.y, point.z); existing.position.set(point.x, point.y, point.z);
this.applyPresentation(existing, point);
} else { } else {
this.addLabel(point); this.addLabel(point);
} }
} }
} }
/**
* Moves one label that is already up, without going through `update`. For labels whose place
* is fixed relative to the camera rather than to anything in the scene: they have to be
* recomputed every frame, and rebuilding the whole label set at that rate would throw away
* the diffing that keeps the DOM still.
*/
moveLabel(id: number | string, x: number, y: number, z: number): void {
this.labelObjects.get(id)?.position.set(x, y, z);
}
/**
* Marks the selected object in the scene: two thin arcs bracketing the point, the one thing
* borrowed from the ARK's control disc. `null` clears it. Kept out of `update` because it is
* a different rhythm — labels change on their own cadence, the mark follows a moving body
* every frame.
*/
setSelection(point: SelectionPoint | null): void {
if (!point) {
if (this.selection) {
this.scene.remove(this.selection);
this.selection.element.remove();
this.selection = undefined;
}
return;
}
if (!this.selection) {
const element = document.createElement('div');
element.className = 'map-select';
element.setAttribute('aria-hidden', 'true');
this.selection = new CSS2DObject(element);
this.scene.add(this.selection);
}
this.selection.position.set(point.x, point.y, point.z);
}
render(camera: THREE.Camera): void { render(camera: THREE.Camera): void {
this.cssRenderer.render(this.scene, camera); this.cssRenderer.render(this.scene, camera);
} }
@@ -75,15 +154,30 @@ export class StarLabelOverlay {
for (const [id, object] of this.labelObjects) { for (const [id, object] of this.labelObjects) {
this.removeLabel(id, object); this.removeLabel(id, object);
} }
this.setSelection(null);
} }
private addLabel(point: LabeledPoint): void { private addLabel(point: LabeledPoint): void {
const element = document.createElement('div'); // A selectable label is a real button, so it is reachable by keyboard and announced as an
// action rather than as text that happens to respond to a click.
const element = document.createElement(point.selectStarId === undefined ? 'div' : 'button');
if (point.selectStarId !== undefined) {
const starId = point.selectStarId;
(element as HTMLButtonElement).type = 'button';
// Read out as a sentence rather than as the two lines run together — the name and the
// distance are adjacent spans, so the default accessible name is "Sirius2.64 pc" — and
// said as the action it is, since nothing else on screen says these labels are doors.
element.setAttribute('aria-label', `Go to ${point.name}${point.kind ? `, ${point.kind} away` : ''}`);
element.addEventListener('click', (event) => {
event.stopPropagation();
this.onSelectStar?.(starId);
});
}
// Classes assigned directly since this element lives outside Angular's view encapsulation // Classes assigned directly since this element lives outside Angular's view encapsulation
// (see the class comment above). The offset and leader line live in `.map-label` itself: // (see the class comment above). The offset and leader line live in `.map-label` itself:
// CSS2DRenderer rewrites this element's inline transform every frame, so a translate here // CSS2DRenderer rewrites this element's inline transform every frame, so a translate here
// would be overwritten — the margin is the offset it cannot touch. // would be overwritten — the margin is the offset it cannot touch.
element.className = 'map-label whitespace-nowrap font-body'; element.className = classesFor(point);
const name = document.createElement('span'); const name = document.createElement('span');
name.className = 'map-label-name'; name.className = 'map-label-name';
@@ -98,16 +192,25 @@ export class StarLabelOverlay {
} }
const object = new CSS2DObject(element); const object = new CSS2DObject(element);
// Anchor the label's left edge at the point, vertically centred. The default center of // Anchor the label's near edge at the point, vertically centred. The default center of
// (0.5, 0.5) makes CSS2DRenderer emit translate(-50%,-50%), keeping the box centred on the // (0.5, 0.5) makes CSS2DRenderer emit translate(-50%,-50%), keeping the box centred on the
// star — under which `.map-label`'s margin offset only nudges the centred box sideways and // star — under which `.map-label`'s margin offset only nudges the centred box sideways and
// the leader line points at empty space half the label's width from the star. // the leader line points at empty space half the label's width from the star.
object.center.set(0, 0.5); this.applyPresentation(object, point);
object.position.set(point.x, point.y, point.z); object.position.set(point.x, point.y, point.z);
this.scene.add(object); this.scene.add(object);
this.labelObjects.set(point.id, object); this.labelObjects.set(point.id, object);
} }
/** Right-hand text hangs its left edge on the point; left-hand text hangs its right edge. */
private applyPresentation(object: CSS2DObject, point: LabeledPoint): void {
object.center.set(point.side === 'left' ? 1 : 0, 0.5);
const wanted = classesFor(point);
if (object.element.className !== wanted) {
object.element.className = wanted;
}
}
private removeLabel(id: number | string, object: CSS2DObject): void { private removeLabel(id: number | string, object: CSS2DObject): void {
this.scene.remove(object); this.scene.remove(object);
object.element.remove(); object.element.remove();
@@ -67,28 +67,6 @@ describe('StarmapHudComponent', () => {
expect(emitted).toEqual(['galactic', 'galaxy']); expect(emitted).toEqual(['galactic', 'galaxy']);
}); });
it('renders the readout panel from its inputs', () => {
fixture.componentRef.setInput('level', 'galactic');
fixture.componentRef.setInput('eyebrow', 'Galactic Scale');
fixture.componentRef.setInput('title', 'Milky Way');
fixture.componentRef.setInput('subtitle', 'Barred spiral');
fixture.componentRef.setInput('readouts', [{ label: 'Arms', value: '5' }]);
fixture.componentRef.setInput('note', 'Illustrative model.');
fixture.componentRef.setInput('range', '21.5 kpc');
fixture.detectChanges();
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
for (const expected of ['Galactic Scale', 'Milky Way', 'Barred spiral', 'Arms', '5', 'Illustrative model.', '21.5 kpc']) {
expect(text).toContain(expected);
}
});
it('leaves out the optional lines it was given nothing for', () => {
const host = render('galaxy');
expect(host.querySelector('dl')).toBeNull();
expect(host.textContent).not.toContain('undefined');
});
it('names what the view is holding on the banner across the top', () => { it('names what the view is holding on the banner across the top', () => {
fixture.componentRef.setInput('level', 'system'); fixture.componentRef.setInput('level', 'system');
fixture.componentRef.setInput('title', 'Sol'); fixture.componentRef.setInput('title', 'Sol');
@@ -101,4 +79,17 @@ describe('StarmapHudComponent', () => {
// An empty nameplate is worse than none: it reads as a selection that failed to resolve. // An empty nameplate is worse than none: it reads as a selection that failed to resolve.
expect(render('galaxy').querySelector('[data-testid="hud-banner"]')).toBeNull(); expect(render('galaxy').querySelector('[data-testid="hud-banner"]')).toBeNull();
}); });
it('shows the scale it is given, as a bar of that width', () => {
fixture.componentRef.setInput('scale', { label: '10 pc', widthPx: 100 });
const bar = render('galaxy').querySelector<HTMLElement>('[data-testid="hud-scale"]');
expect(bar?.getAttribute('aria-label')).toBe('Scale: 10 pc');
expect(bar?.textContent?.trim()).toBe('10 pc');
expect(bar?.querySelector<HTMLElement>('span:last-child')?.style.width).toBe('100px');
});
it('shows no scale bar while there is no scale to show', () => {
expect(render('galaxy').querySelector('[data-testid="hud-scale"]')).toBeNull();
});
}); });
@@ -1,19 +1,9 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { ScaleBar } from '../../shared/format/scale-bar';
import { ViewLevel } from '../../shared/state/navigation.store'; import { ViewLevel } from '../../shared/state/navigation.store';
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component'; import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
export interface HudReadout {
readonly label: string;
readonly value: string;
/**
* True when the figure was computed from other measurements rather than catalogued directly.
* Marked in the panel and explained in its footnote, so a reasoned number is never mistaken for
* an observed one.
*/
readonly derived?: boolean;
}
interface LadderStep { interface LadderStep {
readonly level: ViewLevel; readonly level: ViewLevel;
readonly label: string; readonly label: string;
@@ -31,9 +21,9 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
]; ];
/** /**
* The map's heads-up display: the scale ladder down the left, the readout panel across the * The top of the map's heads-up display: the scale ladder on the left with the scale bar under
* bottom, a centre reticle on whatever the camera is holding, and the frame brackets around * it, the nameplate across the centre, and a centre reticle on whatever the camera is holding.
* the whole viewport. * Readouts and tools live in the dock along the bottom (`HudDockComponent`).
* *
* Purely presentational — every value arrives as an input and the only thing it emits is a * Purely presentational — every value arrives as an input and the only thing it emits is a
* request to move to another scale. The scene owns the camera and decides what that means. * request to move to another scale. The scene owns the camera and decides what that means.
@@ -49,8 +39,6 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
imports: [ReticleIconComponent], imports: [ReticleIconComponent],
host: { class: 'pointer-events-none absolute inset-0 block select-none' }, host: { class: 'pointer-events-none absolute inset-0 block select-none' },
template: ` template: `
<div class="hud-vignette absolute inset-0"></div>
@if (showReticle()) { @if (showReticle()) {
<!-- The same circle-and-ticks reticle the search field wears, scaled up: one lock mark <!-- The same circle-and-ticks reticle the search field wears, scaled up: one lock mark
for the whole instrument, whether it is holding a query or a body. --> for the whole instrument, whether it is holding a query or a body. -->
@@ -58,8 +46,8 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
} }
<!-- Top rail: which scale the view is at, and what it is holding. Both sit on one line <!-- Top rail: which scale the view is at, and what it is holding. Both sit on one line
across the top of the display, clear of the search field above them. --> across the top of the display; the search lives in the dock below, so nothing sits above. -->
<nav aria-label="Map scale" class="hud-brackets hud-surface pointer-events-auto absolute top-16 left-6 flex items-stretch divide-x divide-border/40"> <nav aria-label="Map scale" class="hud-brackets hud-surface pointer-events-auto absolute top-6 left-6 flex items-stretch divide-x divide-border/40">
@for (step of ladder(); track step.level) { @for (step of ladder(); track step.level) {
@if (step.reachable) { @if (step.reachable) {
<button <button
@@ -81,62 +69,35 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
} }
</nav> </nav>
@if (scale(); as bar) {
<!-- The map's scale bar, under the rail that names the scale: a round length, measured at the
depth the view is centred on, since under perspective every depth has its own. -->
<div data-testid="hud-scale" role="img" [attr.aria-label]="'Scale: ' + bar.label" class="absolute top-16 left-6 flex flex-col items-start gap-1">
<span class="type-label text-muted tabular-nums">{{ bar.label }}</span>
<span class="block h-1.5 border-x border-b border-accent/70" [style.width.px]="bar.widthPx"></span>
</div>
}
@if (title()) { @if (title()) {
<!-- Hidden below lg: the readout panel names the same thing, and at narrower widths a <!-- Hidden below lg: the readout panel names the same thing, and at narrower widths a
long star name runs into the scale rail on its left and under the object card on its long star name runs into the scale rail on its left and under the object card on its
right — all three share the top-16 line. --> right — all three share the top-6 line. -->
<div class="absolute top-16 left-1/2 hidden -translate-x-1/2 lg:block"> <div class="absolute top-6 left-1/2 hidden -translate-x-1/2 lg:block">
<div data-testid="hud-banner" class="hud-brackets hud-acquire hud-surface flex items-center gap-2.5 px-6 py-1.5"> <div data-testid="hud-banner" class="hud-brackets hud-acquire hud-surface flex items-center gap-2.5 px-6 py-1.5">
<app-reticle-icon class="h-3 w-3 shrink-0 text-accent" /> <app-reticle-icon class="h-3 w-3 shrink-0 text-accent" />
<span class="text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span> <span class="text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span>
</div> </div>
</div> </div>
} }
<div class="absolute right-6 bottom-6 left-6 flex flex-wrap items-end justify-between gap-4">
<div class="hud-brackets hud-acquire hud-surface max-w-lg px-4 py-3">
<p class="type-label text-muted">{{ eyebrow() }}</p>
<p data-testid="hud-title" class="mt-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
@if (subtitle()) {
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
}
@if (readouts().length) {
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
@for (readout of readouts(); track readout.label) {
<div>
<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>
</div>
}
</dl>
}
@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>
}
</div>
<div class="hud-brackets hud-acquire hud-surface px-4 py-3 text-right">
<p class="type-label text-muted">Range</p>
<p class="mt-1 text-lg text-accent tabular-nums">{{ range() }}</p>
</div>
</div>
` `
}) })
export class StarmapHudComponent { export class StarmapHudComponent {
readonly level = input.required<ViewLevel>(); readonly level = input.required<ViewLevel>();
/** Headline for the readout panel — the selected star, or the name of the current scale. */ /** What the view is holding, for the nameplate — the selected star, or nothing. */
readonly title = input(''); readonly title = input('');
readonly subtitle = input('');
readonly eyebrow = input('');
readonly readouts = input<readonly HudReadout[]>([]);
/** Standing caveat for the current view, e.g. that galactic structure is a model. */
readonly note = input('');
/** Whether any readout needs the derived-value footnote. */
readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived));
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
readonly range = input('');
readonly showReticle = input(true); readonly showReticle = input(true);
/** The scale bar for the current zoom, worked out by the scene, which knows the camera. */
readonly scale = input<ScaleBar | null>(null);
readonly levelSelected = output<ViewLevel>(); readonly levelSelected = output<ViewLevel>();
@@ -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,
@@ -19,6 +18,10 @@ import {
const TRAPPIST_1 = { innermost: 0.01154, outermost: 0.06189 }; const TRAPPIST_1 = { innermost: 0.01154, outermost: 0.06189 };
const GL_357 = { innermost: 0.035, outermost: 0.204 }; const GL_357 = { innermost: 0.035, outermost: 0.204 };
const SOLAR = { innermost: 0.387, outermost: 30.07 }; const SOLAR = { innermost: 0.387, outermost: 30.07 };
/** The solar system as the map draws it: out to Eris's semi-major axis, 67.93 AU. */
const SOLAR_TO_ERIS = { innermost: 0.387, outermost: 67.93 };
/** Eris's aphelion, a(1 + e) = 67.934 x 1.4382: past the 80 AU ring its semi-major axis gives. */
const ERIS_APHELION_AU = 97.7;
describe('starMarkerRadiusAu', () => { describe('starMarkerRadiusAu', () => {
it('never reaches the innermost orbit', () => { it('never reaches the innermost orbit', () => {
@@ -113,107 +116,12 @@ 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. */ /**
* The grid's half of what the scene composes: rings from the orbits, then a distance from the outer
* ring. The scene frames the larger of that ring and the furthest aphelion (`outermostRadiusAu`),
* which the Eris test below frames where it runs past the ring, and the scene's own spec checks.
*/
function fit(outermostOrbitAu: number, viewport?: SystemViewport): { ring: number; frame: number } { function fit(outermostOrbitAu: number, viewport?: SystemViewport): { ring: number; frame: number } {
const rings = systemGridRingsAu(outermostOrbitAu); const rings = systemGridRingsAu(outermostOrbitAu);
const ring = rings[rings.length - 1]; const ring = rings[rings.length - 1];
@@ -223,14 +131,15 @@ describe('the grid and the framing together', () => {
const VIEWPORTS: SystemViewport[] = [ const VIEWPORTS: SystemViewport[] = [
{ fovDegrees: 50, aspect: 1.78 }, { fovDegrees: 50, aspect: 1.78 },
{ fovDegrees: 50, aspect: 1 }, { fovDegrees: 50, aspect: 1 },
{ fovDegrees: 50, aspect: 0.6 } { fovDegrees: 50, aspect: 0.6 },
{ fovDegrees: 50, aspect: 390 / 844 } // a phone held upright
]; ];
it('leaves the outermost ring clear of the frame edge at every scale and window shape', () => { it('leaves the outermost ring clear of the frame edge at every scale and window shape', () => {
// The whole point of framing against the grid rather than the orbits: before this, 368 of // The whole point of framing against the grid rather than the orbits: before this, 368 of
// the 371 systems in the datasets drew a grid wider than the view that was meant to hold it. // the 371 systems in the datasets drew a grid wider than the view that was meant to hold it.
for (const viewport of VIEWPORTS) { for (const viewport of VIEWPORTS) {
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) { for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, SOLAR_TO_ERIS, { outermost: 1 }, { outermost: 12.4 }]) {
const { ring, frame } = fit(outermost, viewport); const { ring, frame } = fit(outermost, viewport);
expect(ring).toBeLessThan(frame); expect(ring).toBeLessThan(frame);
expect(ring / frame).toBeLessThan(0.93); expect(ring / frame).toBeLessThan(0.93);
@@ -238,6 +147,14 @@ describe('the grid and the framing together', () => {
} }
}); });
it('leaves Eris’s aphelion its whole margin in every window shape, a phone held upright included', () => {
// The scene frames the aphelion where it runs past the ring. Under the old 500 AU ceiling the
// phone would hold it at 0.907 of the half-width instead of 1 / 1.12 = 0.893.
for (const viewport of VIEWPORTS) {
expect(ERIS_APHELION_AU / systemFrameRadiusAu(systemFramingDistanceAu(ERIS_APHELION_AU, viewport), viewport)).toBeLessThan(0.9);
}
});
it('still encloses the outermost orbit, so no planet sits off the edge of the grid', () => { it('still encloses the outermost orbit, so no planet sits off the edge of the grid', () => {
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) { for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) {
expect(fit(outermost).ring).toBeGreaterThan(outermost); expect(fit(outermost).ring).toBeGreaterThan(outermost);
@@ -278,54 +195,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
@@ -86,13 +61,17 @@ const MIN_FRAMING_DISTANCE_AU = 0.06;
/** /**
* Ceiling on the framing distance, so a distant companion does not push the star to a dot. * Ceiling on the framing distance, so a distant companion does not push the star to a dot.
* *
* Generous enough to frame the solar system out to Pluto in any window shape, which needs 120 AU * Generous enough to frame the solar system out to Eris in any window a reader holds: Eris's
* on a landscape display and 140 on a portrait one once the camera's real field of view is * aphelion, 97.7 AU, the furthest it draws, needs 235 AU on a landscape display and 508 on a 390
* accounted for. Only genuinely pathological systems reach it now — the handful with * by 844 phone once the camera's real field of view is accounted for, and 600 holds it down to an
* directly-imaged companions hundreds of AU out — and those still arrive framed on their inner * aspect of 0.39. At 500, framed on the 80 AU grid ring inside that aphelion, a phone arrived with
* region, with the orbit controls reaching far enough to pull back to the rest. * Eris's orbit 3.5 px from the edge; at 200, which framed Pluto's 40 AU ring, a portrait window
* arrived with Eris off screen, and a phone with Makemake too. Only genuinely pathological systems
* reach it now — the handful with directly-imaged companions hundreds of AU out — and those still
* arrive framed on their inner region, with the orbit controls reaching far enough to pull back
* to the rest.
*/ */
const MAX_FRAMING_DISTANCE_AU = 200; const MAX_FRAMING_DISTANCE_AU = 600;
/** Framing for a star with no known planets, where there is nothing to fit. */ /** Framing for a star with no known planets, where there is nothing to fit. */
const EMPTY_SYSTEM_FRAMING_DISTANCE_AU = 3; const EMPTY_SYSTEM_FRAMING_DISTANCE_AU = 3;
@@ -150,20 +129,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.
@@ -174,8 +139,9 @@ export function starGlowExtentAu(markerRadiusAu: number, frameRadiusAu: number,
* was tuned by eye against a 55-degree field, and the engine's camera is 50 — which left the * was tuned by eye against a 55-degree field, and the engine's camera is 50 — which left the
* grid overflowing the frame in 368 of the 371 systems the datasets contain. * grid overflowing the frame in 368 of the 371 systems the datasets contain.
* *
* Callers pass the outermost thing actually drawn, which is the reference grid's outer ring * Callers pass the outermost thing actually drawn: the reference grid's outer ring, which runs past
* rather than the outermost orbit — the ring is always the wider of the two, by construction. * every semi-major axis by construction, or an eccentric orbit's aphelion where that runs past the
* ring, as Eris's does.
*/ */
export function systemFramingDistanceAu(framedRadiusAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT): number { export function systemFramingDistanceAu(framedRadiusAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT): number {
if (!Number.isFinite(framedRadiusAu) || framedRadiusAu <= 0) { if (!Number.isFinite(framedRadiusAu) || framedRadiusAu <= 0) {
@@ -223,32 +189,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;
} }
@@ -1,7 +1,9 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core';
import { BodyDetailViewModel } from '../body-detail/body-detail.model'; import { BodyDetailViewModel } from '../body-detail/body-detail.model';
import { bodyReadouts } from '../body-detail/body-readouts'; import { bodyReadouts } from '../body-detail/body-readouts';
import { BookmarksStore } from '../../shared/state/bookmarks.store';
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
import { ReadoutSectionsComponent } from '../body-detail/readout-sections.component'; import { ReadoutSectionsComponent } from '../body-detail/readout-sections.component';
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
@@ -19,7 +21,7 @@ import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
@Component({ @Component({
selector: 'app-system-object-card', selector: 'app-system-object-card',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ChevronIconComponent, ReadoutSectionsComponent], imports: [BookmarkIconComponent, ChevronIconComponent, ReadoutSectionsComponent],
// Positioned and full-bleed like the HUD's own host, so the panel inside it resolves against // Positioned and full-bleed like the HUD's own host, so the panel inside it resolves against
// the scene rather than against whatever box the inline default would have left it in — which // the scene rather than against whatever box the inline default would have left it in — which
// put the card off the bottom-left corner of the viewport entirely. // put the card off the bottom-left corner of the viewport entirely.
@@ -29,13 +31,23 @@ import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
never fights the absolute placement. Same organism as the detail page's info panel: never fights the absolute placement. Same organism as the detail page's info panel:
header, shared readout sections, and a route rail — there at the top, here at the header, shared readout sections, and a route rail — there at the top, here at the
bottom, because here the route is the next step rather than the way back. --> bottom, because here the route is the next step rather than the way back. -->
<div class="pointer-events-auto absolute top-16 right-6 w-80 max-w-[calc(100%-3rem)]"> <div class="pointer-events-auto absolute top-6 right-6 w-80 max-w-[calc(100%-3rem)]">
<div data-testid="object-card" class="hud-brackets hud-acquire hud-surface font-body text-text"> <div data-testid="object-card" class="hud-brackets hud-acquire hud-surface font-body text-text">
<div class="flex items-start justify-between gap-3 px-4 pt-4 pb-3"> <div class="flex items-start justify-between gap-3 px-4 pt-4 pb-3">
<header class="min-w-0"> <header class="min-w-0">
<p class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</p> <p class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</p>
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p> <p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
</header> </header>
<button
type="button"
[attr.aria-label]="(bookmarks.has('body', body().id) ? 'Forget ' : 'Keep ') + body().name"
[attr.aria-pressed]="bookmarks.has('body', body().id)"
(click)="bookmarks.toggle({ kind: 'body', id: body().id, name: body().name })"
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
[class]="bookmarks.has('body', body().id) ? 'text-accent' : 'text-muted hover:text-accent'"
>
<app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('body', body().id)" />
</button>
<button <button
type="button" type="button"
(click)="dismissed.emit()" (click)="dismissed.emit()"
@@ -69,5 +81,7 @@ export class SystemObjectCardComponent {
/** Request for the full `/body/:id` route. */ /** Request for the full `/body/:id` route. */
readonly openRequested = output<void>(); readonly openRequested = output<void>();
readonly bookmarks = inject(BookmarksStore);
readonly readouts = computed(() => bodyReadouts(this.body())); readonly readouts = computed(() => bodyReadouts(this.body()));
} }
@@ -1,11 +1,21 @@
import * as THREE from 'three/webgpu'; /// <reference types="node" />
import { describe, expect, it } from 'vitest';
import { DEFAULT_EPOCH_JD } from '../../shared/astro/constants'; import { readFileSync } from 'node:fs';
import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates'; import * as THREE from 'three/webgpu';
import { describe, expect, it, vi } from 'vitest';
import { DEFAULT_EPOCH_JD, GM_SUN_AU3_PER_DAY2, ttMinusUtSeconds } from '../../shared/astro/constants';
import { keplerRates } from '../../shared/astro/kepler';
import { eclipticToEquatorial, laplacePlaneToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
import { orientationAt } from '../../shared/astro/rotational-elements';
import { BodyRecord } from '../../shared/models/body.model'; import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { SystemOrbitsRenderer } from './system-orbits-renderer'; import { SystemOrbitsRenderer } from './system-orbits-renderer';
import { bodyTexturePath, loadCachedTexture } from '../../shared/rendering/texture-catalog';
import { bodyMarkerRadiusAu } from './system-framing';
/** The clock's UT date that names a TDB one: TT - UT, which moves by under a second a year, earlier. */
const utOf = (jdTdb: number): number => jdTdb - ttMinusUtSeconds(jdTdb) / 86400;
/** TRAPPIST-1 b: a real short-period planet around a 0.09 solar-mass red dwarf. */ /** TRAPPIST-1 b: a real short-period planet around a 0.09 solar-mass red dwarf. */
const TRAPPIST_1B_SEMI_MAJOR_AXIS_AU = 0.01154; const TRAPPIST_1B_SEMI_MAJOR_AXIS_AU = 0.01154;
@@ -164,7 +174,8 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => {
argumentOfPeriapsisDeg: 0, argumentOfPeriapsisDeg: 0,
meanAnomalyAtEpochDeg: 0, meanAnomalyAtEpochDeg: 0,
epochJd: DEFAULT_EPOCH_JD epochJd: DEFAULT_EPOCH_JD
} },
rates: keplerRates(1, GM_SUN_AU3_PER_DAY2), orbitSource: 'test'
}; };
it('places an ecliptic orbit in the ecliptic plane of the equatorial scene', () => { it('places an ecliptic orbit in the ecliptic plane of the equatorial scene', () => {
@@ -201,7 +212,8 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => {
// A body at ecliptic longitude 0 sits on the +X axis in both frames, so it must not move. // A body at ecliptic longitude 0 sits on the +X axis in both frames, so it must not move.
const atEquinox: BodyRecord = { ...EARTH, orbit: { ...EARTH.orbit, eccentricity: 0 } }; const atEquinox: BodyRecord = { ...EARTH, orbit: { ...EARTH.orbit, eccentricity: 0 } };
const renderer = new SystemOrbitsRenderer([atEquinox], []); const renderer = new SystemOrbitsRenderer([atEquinox], []);
renderer.update(DEFAULT_EPOCH_JD); // The clock's UT date whose TDB is the elements' epoch.
renderer.update(utOf(DEFAULT_EPOCH_JD));
const p = renderer.members[0].marker.position; const p = renderer.members[0].marker.position;
expect(p.x).toBeCloseTo(1, 6); expect(p.x).toBeCloseTo(1, 6);
@@ -234,7 +246,9 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => {
name: 'Jupiter', name: 'Jupiter',
kind: 'planet', kind: 'planet',
radiusKm: 69911, radiusKm: 69911,
orbit: { semiMajorAxisAu: 5.2, eccentricity: 0.048, inclinationDeg: 1.3, longitudeOfAscendingNodeDeg: 100, argumentOfPeriapsisDeg: 275, meanAnomalyAtEpochDeg: 20, epochJd: DEFAULT_EPOCH_JD } orbit: { semiMajorAxisAu: 5.2, eccentricity: 0.048, inclinationDeg: 1.3, longitudeOfAscendingNodeDeg: 100, argumentOfPeriapsisDeg: 275, meanAnomalyAtEpochDeg: 20, epochJd: DEFAULT_EPOCH_JD },
rates: keplerRates(5.2, GM_SUN_AU3_PER_DAY2),
orbitSource: 'test'
}; };
/** The grid and the tethers are the only line objects the renderer adds outside a pivot. */ /** The grid and the tethers are the only line objects the renderer adds outside a pivot. */
@@ -372,3 +386,622 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => {
}); });
}); });
}); });
describe('rotation without IAU elements', () => {
/** A body with a day of 23.934 h and no pole: Eris, Haumea and Makemake are drawn this way. */
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 },
rates: keplerRates(1, GM_SUN_AU3_PER_DAY2), orbitSource: 'test',
rotationPeriodHours: 23.934,
...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 it about its orbit’s normal, backwards for a negative period', () => {
expect(spinSense(spinning({ rotationPeriodHours: -23.934 }))).toBeLessThan(-0.99);
expect(spinSense(spinning({ rotationPeriodHours: 23.934 }))).toBeGreaterThan(0.99);
});
it('turns Nereid, as shipped, once in the 11.594 hours Kepler measured: a sixth of a turn in 1.93 hours', () => {
const shipped: BodyRecord[] = JSON.parse(readFileSync(`${process.cwd()}/src/assets/data/bodies.json`, 'utf8'));
const renderer = new SystemOrbitsRenderer(shipped.filter((body) => body.id === 'neptune' || body.id === 'nereid'), []);
const nereid = renderer.members.find((member) => member.id === 'nereid')!.marker;
renderer.update(DEFAULT_EPOCH_JD);
const start = nereid.quaternion.clone();
renderer.update(DEFAULT_EPOCH_JD + 11.594 / 6 / 24);
expect((nereid.quaternion.angleTo(start) * 180) / Math.PI).toBeCloseTo(60, 1);
});
it('leaves a body with no published rotation still', () => {
// Hyperion, which tumbles: an invented period 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('outermostRadiusAu', () => {
function drawn(axis: number, eccentricity: number): BodyRecord {
return {
id: 'eris', systemStarId: 0, name: 'Eris', kind: 'dwarf', radiusKm: 1163, orbitSource: 'test',
orbit: { semiMajorAxisAu: axis, eccentricity, inclinationDeg: 44, longitudeOfAscendingNodeDeg: 36, argumentOfPeriapsisDeg: 151, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD },
rates: keplerRates(axis, GM_SUN_AU3_PER_DAY2)
};
}
it('reaches as far as an eccentric orbit goes past the grid: Eris’s aphelion, 97.7 AU, not the 80 AU ring', () => {
const renderer = new SystemOrbitsRenderer([drawn(67.934, 0.4382)], []);
expect(renderer.outermostRadiusAu).toBeCloseTo(67.934 * 1.4382, 9);
renderer.dispose();
});
it('reaches an exoplanet’s aphelion too: HD 20782 b’s, 1.66 times its 1.6 AU ring', () => {
// The most eccentric of the 303 exoplanet systems with an orbit past their ring, counted on
// exoplanets.json (a = 1.3649 AU, e = 0.95).
const renderer = new SystemOrbitsRenderer([], [exoplanet({ id: 'HD 20782 b', name: 'HD 20782 b', orbit: { semiMajorAxisAu: 1.3649, eccentricity: 0.95 } })]);
expect(renderer.outermostRadiusAu).toBeCloseTo(1.3649 * 1.95, 9);
renderer.dispose();
});
it('is the grid’s outer ring where every orbit stays inside it', () => {
const renderer = new SystemOrbitsRenderer([drawn(30, 0.01)], []);
expect(renderer.outermostRadiusAu).toBe(35);
renderer.dispose();
});
});
describe('photographs', () => {
it('puts them on their bodies once loaded, one a frame, so the GPU is not handed every map at once, and in their own colours', () => {
// Ids no other test here draws, since the loaded textures are shared through the cache.
const ids = ['ganymede', 'callisto'];
const records: BodyRecord[] = ids.map((id, index) => ({
id, systemStarId: 0, name: id, kind: 'planet', radiusKm: 2500, orbitSource: 'test',
orbit: { semiMajorAxisAu: 1 + index, eccentricity: 0, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD },
rates: keplerRates(1 + index, GM_SUN_AU3_PER_DAY2)
}));
const renderer = new SystemOrbitsRenderer(records, []);
const materials = (): THREE.MeshStandardMaterial[] => renderer.members.map((member) => (member.marker as THREE.Mesh).material as THREE.MeshStandardMaterial);
const maps = (): Array<THREE.Texture | null> => materials().map((material) => material.map);
const colours = (): number[] => materials().map((material) => material.color.getHex());
renderer.update(DEFAULT_EPOCH_JD);
expect(maps()).toEqual([null, null]); // not loaded yet: jsdom never loads an image
// Until then each is its kind's flat colour, a planet's pale blue.
expect(colours()).toEqual([new THREE.Color(0.55, 0.75, 1).getHex(), new THREE.Color(0.55, 0.75, 1).getHex()]);
for (const id of ids) {
loadCachedTexture(bodyTexturePath(id)!).image = { width: 2, height: 1 };
}
renderer.update(DEFAULT_EPOCH_JD);
expect(maps().filter(Boolean)).toHaveLength(1);
renderer.update(DEFAULT_EPOCH_JD);
expect(maps()).toEqual(ids.map((id) => loadCachedTexture(bodyTexturePath(id)!)));
// The material multiplies its map by its colour: left pale blue, every planet's photograph would
// be tinted, Mars's red cut by 45 per cent.
expect(colours()).toEqual([0xffffff, 0xffffff]);
renderer.dispose();
});
});
describe('markers', () => {
it('draws every body on the one sphere, scaled to its radius, and leaves that sphere when a system is left', () => {
const records: BodyRecord[] = [2500, 60000].map((radiusKm, index) => ({
id: `body-${index}`, systemStarId: 0, name: `Body ${index}`, kind: 'planet', radiusKm, orbitSource: 'test',
orbit: { semiMajorAxisAu: 1 + index, eccentricity: 0, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD },
rates: keplerRates(1 + index, GM_SUN_AU3_PER_DAY2)
}));
const renderer = new SystemOrbitsRenderer(records, [exoplanet({ radiusEarth: 1.1 })], undefined, 1);
const meshes = renderer.members.map((member) => member.marker as THREE.Mesh);
expect(new Set(meshes.map((mesh) => mesh.geometry)).size).toBe(1);
[2500, 60000, 1.1 * 6371].forEach((radiusKm, index) => {
const sphere = meshes[index].geometry as THREE.SphereGeometry;
expect(meshes[index].scale.x * sphere.parameters.radius).toBeCloseTo(bodyMarkerRadiusAu(radiusKm), 12);
});
const disposed = vi.fn();
meshes[0].geometry.addEventListener('dispose', disposed);
renderer.dispose();
expect(disposed).not.toHaveBeenCalled();
});
});
describe('derived surfaces', () => {
const maps = (renderer: SystemOrbitsRenderer): Array<THREE.Texture | null> =>
renderer.members.map((member) => ((member.marker as THREE.Mesh).material as THREE.MeshStandardMaterial).map);
const nextTask = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
const twoPlanets = (): SystemOrbitsRenderer =>
new SystemOrbitsRenderer([], [exoplanet({ radiusEarth: 1.1 }), exoplanet({ id: 'TRAPPIST-1 c', name: 'TRAPPIST-1 c', radiusEarth: 1.0 })], undefined, 1);
it('paints them after the system is built, one a task, so entering a system is not held up, and in their own colours', async () => {
const colours = (renderer: SystemOrbitsRenderer): number[] =>
renderer.members.map((member) => ((member.marker as THREE.Mesh).material as THREE.MeshStandardMaterial).color.getHex());
const renderer = twoPlanets();
expect(maps(renderer)).toEqual([null, null]);
// Until then each is its kind's flat colour, an exoplanet's magenta.
expect(colours(renderer)).toEqual([new THREE.Color(0.85, 0.4, 0.85).getHex(), new THREE.Color(0.85, 0.4, 0.85).getHex()]);
await nextTask();
expect(maps(renderer).filter(Boolean)).toHaveLength(1);
await nextTask();
expect(maps(renderer).every(Boolean)).toBe(true);
// Left magenta, every derived surface would be multiplied by it, its green cut by 60 per cent.
expect(colours(renderer)).toEqual([0xffffff, 0xffffff]);
renderer.dispose();
});
it('builds a body still waiting for its surface without three warning of an undefined map', () => {
const warn = vi.spyOn(console, 'warn');
twoPlanets().dispose();
expect(warn.mock.calls.flat().join(' ')).not.toContain("parameter 'map'");
warn.mockRestore();
});
it('paints nothing once the system is left', async () => {
const renderer = twoPlanets();
renderer.dispose();
await nextTask();
expect(maps(renderer)).toEqual([null, null]);
});
});
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.userData['radiusAu'];
};
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);
});
});
describe('solar-system bodies against Horizons', () => {
// The records the app ships, read from bodies.json with their IAU rotational elements, and
// Horizons' own positions for them (ICRF, AU; heliocentric for the planets, planet-centred for the
// moons) at dates across 1950-2100, so the whole path — the ETL's reading of the mean elements,
// their rates, the Laplace planes and the scene's frame — is checked against JPL's ephemeris rather
// than against itself. A hand copy of the records stood here, and an ETL that dropped Standish's a,
// e and i rates or Io's and Europa's backward periapses passed the whole suite on the data it
// wrote. Horizons' dates are TDB and the renderer's are the clock's UT, so each is handed over
// TT - UT earlier: 69.184 s today, 29 in 1950.
const SHIPPED: BodyRecord[] = JSON.parse(readFileSync(`${process.cwd()}/src/assets/data/bodies.json`, 'utf8'));
// Mimas and Phobos among them for the terms of their IAU W that are motion along the orbit: the
// Mimas-Tethys libration and Phobos's tidal acceleration (see `orbitalTermsOfPrimeMeridian`).
const IDS = ['earth', 'jupiter', 'saturn', 'neptune', 'pluto', 'moon', 'io', 'europa', 'titan', 'triton', 'uranus', 'titania', 'charon', 'venus', 'mars', 'mimas', 'phobos'];
// Each ceiling sits just above what these elements measure on that date: Earth 0.003 degrees,
// Jupiter 0.063, Saturn 0.164, Pluto 0.054, the Moon 0.72 (no mean ellipse has its evection or
// variation), Io 0.021, Europa 0.036, Titan 0.014, Triton 0.137, Titania 0.62 (against Uranus's
// equator, 120 years from its 1980 epoch), Charon 0.37, Mimas 2.24 on 2026 May 27, when its libration has it
// 44 degrees ahead of its mean motion (43.3 without the term), and Phobos 1.25 in 2100 (11.1 without its
// tidal acceleration).
const HORIZONS: Array<[id: string, jd: number, x: number, y: number, z: number, maxDeg: number]> = [
['earth', 2488069.5, -0.1574071329883954, 0.890666220858489, 0.3859132211165683, 0.02],
// AD 3000, the end of the clock's window and of Standish's fit: the Earth-Moon barycentre and
// Saturn's, 0.005 and 0.065 degrees out. Without Standish's rates for a, e and i they were 0.129
// and 0.412, which no date between 1950 and 2100 shows (at most 0.036, Saturn in 2100).
['earth', 2816787.5, 0.06574092668156256, 0.9022934196570718, 0.3887693148519465, 0.02],
['saturn', 2816787.5, 8.434780522117482, 3.87565654130078, 1.235068259814154, 0.1],
['jupiter', 2433282.5, 3.406605247558555, -3.425997624196318, -1.551719750032203, 0.1],
['saturn', 2478938.5, -3.51309768447752, -8.723317933082274, -3.452662390556131, 0.25],
['pluto', 2442413.5, -29.2488165026956, -7.1421817246801, 6.58403957591589, 0.1],
['moon', 2469807.5, 0.00240364781322315, 0.0006554283236619424, 0.0004472719300783614, 2],
['io', 2433282.5, 0.0004488349204269952, 0.002519633434577752, 0.00120678715190893, 0.05],
['europa', 2433282.5, 0.004084372287322533, -0.001665375585011311, -0.0007673072324795899, 0.1],
['titan', 2488069.5, 0.007800850235156121, -0.001556932380983438, -0.0006078959246502567, 0.05],
['triton', 2488069.5, -0.001421151845853369, -0.0001894510477241482, 0.001888790702926415, 0.2],
['titania', 2488069.5, -0.00151919968294745, -0.0003387914082135071, 0.002465657830788125, 0.75],
['charon', 2488069.5, -0.00003046411046017432, -0.000009404114448552256, 0.0001270457155789907, 0.5],
['mimas', 2461187.5, -3.840685116962088e-4, 1.182766232573268e-3, -2.006928678577268e-5, 3],
['phobos', 2488069.5, 4.269855297288105e-5, -2.759158950396543e-5, -3.816269137755616e-5, 1.5],
];
function record(id: string): BodyRecord {
const { kind, orbit, rates, laplacePole, parentBodyId, massRatio, rotationalElements } = SHIPPED.find((body) => body.id === id)!;
return { id, systemStarId: 0, name: id, radiusKm: 1000, orbitSource: 'test', kind, orbit, rates, laplacePole, parentBodyId, massRatio, rotationalElements };
}
const renderer = new SystemOrbitsRenderer(IDS.map(record), []);
for (const [id, jd, x, y, z, maxDeg] of HORIZONS) {
it(`puts ${id} within ${maxDeg} degrees of Horizons on JD ${jd}`, () => {
renderer.update(utOf(jd));
const drawn = renderer.members.find((member) => member.id === id)!.marker.position;
const angleDeg = (drawn.angleTo(new THREE.Vector3(x, y, z)) * 180) / Math.PI;
expect(angleDeg).toBeLessThan(maxDeg);
});
}
it('puts Pluto where Horizons has it round its barycentre with Charon, 2 131 km out and opposite Charon', () => {
// Horizons, Pluto (999) from the Pluto-system barycentre (9), on JD 2488069.5 TDB (2100).
const horizons = new THREE.Vector3(0.000003313612032581019, 0.000001023040948538272, -0.00001381793390079716);
renderer.update(utOf(2488069.5));
const charon = renderer.members.find((member) => member.id === 'charon')!.marker;
const barycentre = charon.parent!.position;
const pluto = renderer.members.find((member) => member.id === 'pluto')!.marker.position.clone().sub(barycentre);
const charonFromBarycentre = charon.position;
expect((pluto.angleTo(horizons) * 180) / Math.PI).toBeLessThan(0.5);
expect(pluto.length() * 149597870.7).toBeCloseTo(horizons.length() * 149597870.7, -1);
// Opposite, at the inverse of their mass ratio.
expect((pluto.angleTo(charonFromBarycentre) * 180) / Math.PI).toBeCloseTo(180, 6);
expect(charonFromBarycentre.length() / pluto.length()).toBeCloseTo(1 / 0.1220485755631374, 6);
});
it('draws Pluto’s own orbit round the barycentre, in the plane it is going round in', () => {
const charon = renderer.members.find((member) => member.id === 'charon')!.marker;
const [charonLine, plutoLine] = charon.parent!.children.filter((child) => child.name === 'orbit-line');
for (const days of [0, 3000, 30000]) {
renderer.update(DEFAULT_EPOCH_JD + days);
const pluto = renderer.members.find((member) => member.id === 'pluto')!.marker.position.clone().sub(charon.parent!.position);
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(plutoLine.quaternion);
expect(Math.abs(pluto.clone().normalize().dot(normal))).toBeLessThan(1e-9);
// A near-circle 2 131 km across, a ninth of Charon's.
expect(Math.abs(plutoLine.scale.x) * 0.00013095774631236113).toBeCloseTo(pluto.length(), 8);
expect(charonLine.scale.x / Math.abs(plutoLine.scale.x)).toBeCloseTo(1 / 0.1220485755631374, 9);
}
});
it('turns a planet’s drawn orbit with its node, so Mars stays on its own line two thousand years out', () => {
// At AD 1 a line fixed at J2000 has Mars 3.3 million km from it, 0.5 million out of its plane.
const mars = renderer.members.find((member) => member.id === 'mars')!.marker;
const line = renderer.object.children[renderer.object.children.indexOf(mars) - 1];
expect(line.name).toBe('orbit-line');
for (const days of [0, -730000]) {
renderer.update(DEFAULT_EPOCH_JD + days);
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(line.quaternion);
expect(Math.abs(mars.position.clone().normalize().dot(normal))).toBeLessThan(1e-9);
}
});
/** How far a top-level body is from its own drawn orbit line, in AU: from the nearest of its chords. */
function offLineAu(id: string): number {
const marker = renderer.members.find((member) => member.id === id)!.marker;
const line = renderer.object.children[renderer.object.children.indexOf(marker) - 1] as THREE.Line;
expect(line.name).toBe('orbit-line');
line.updateMatrixWorld();
const position = line.geometry.getAttribute('position');
const vertex = (index: number): THREE.Vector3 => new THREE.Vector3().fromBufferAttribute(position, index).applyMatrix4(line.matrixWorld);
const chord = new THREE.Line3();
const closest = new THREE.Vector3();
let nearest = Number.POSITIVE_INFINITY;
for (let index = 0; index + 1 < position.count; index++) {
nearest = Math.min(nearest, chord.set(vertex(index), vertex(index + 1)).closestPointToPoint(marker.position, true, closest).distanceTo(marker.position));
}
return nearest;
}
it('redraws a planet’s orbit as its axis and eccentricity drift, so Saturn and Mars stay on their lines at AD 1', () => {
// What is left is the 128 chords' own sag from the true ellipse, which depends on where the
// planet falls between two points: at most 0.0032 AU for Saturn, near aphelion, and 0.00055 for
// Mars. Measured 0.0017 AU for Saturn and 0.0005 for Mars at AD 1, and 0.0011 for Saturn at
// J2000. Drawn at J2000's shape at AD 1, the lines were 0.054 AU from Saturn and 0.0022 from Mars.
const saturnLine = renderer.object.children[renderer.object.children.indexOf(renderer.members.find((member) => member.id === 'saturn')!.marker) - 1] as THREE.Line;
renderer.update(DEFAULT_EPOCH_JD);
const drawnVersion = (saturnLine.geometry.getAttribute('position') as THREE.BufferAttribute).version;
for (const [id, days, maxAu] of [['saturn', -730000, 0.0035], ['mars', -730000, 0.0006], ['saturn', 0, 0.0035]] as const) {
renderer.update(DEFAULT_EPOCH_JD + days);
expect(offLineAu(id)).toBeLessThan(maxAu);
if (days !== 0) {
// Handed to the GPU again, which uploads a buffer only when its version rises: the points
// rewritten on the CPU alone leave J2000's ellipse on screen.
expect((saturnLine.geometry.getAttribute('position') as THREE.BufferAttribute).version).toBeGreaterThan(drawnVersion);
}
}
});
it('turns the Moon’s drawn orbit with its node, so the Moon stays on its own line', () => {
// Half the node's 18.6-year turn on, the ellipse drawn at the epoch has the Moon 10 degrees off
// its plane at the worst.
const moon = renderer.members.find((member) => member.id === 'moon')!.marker;
const line = moon.parent!.children.find((child) => child.name === 'orbit-line')!;
for (const days of [0, 1700, 3397, 3400]) {
renderer.update(DEFAULT_EPOCH_JD + days);
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(line.quaternion);
expect(Math.abs(moon.position.clone().normalize().dot(normal))).toBeLessThan(1e-9);
}
});
const JUNE_1_2025_NOON_UTC = 2460828.0;
/**
* The tilt of a body's drawn spin from the orbit it is drawn going round, in degrees: its angular
* velocity, read off the sphere a quarter of an hour apart, against its orbit line's normal. Past
* 90 is a body turning backwards against its orbit.
*/
function drawnObliquity(id: string): number {
const marker = renderer.members.find((member) => member.id === id)!.marker;
const line = renderer.object.children[renderer.object.children.indexOf(marker) - 1];
expect(line.name).toBe('orbit-line');
renderer.update(JUNE_1_2025_NOON_UTC);
const start = marker.quaternion.clone();
renderer.update(JUNE_1_2025_NOON_UTC + 0.01);
const turn = marker.quaternion.clone().multiply(start.invert());
const spin = new THREE.Vector3(turn.x, turn.y, turn.z).multiplyScalar(Math.sign(turn.w));
return (spin.angleTo(new THREE.Vector3(0, 0, 1).applyQuaternion(line.quaternion)) * 180) / Math.PI;
}
it('turns Venus, Uranus and Pluto backwards against their orbits, at the tilts Horizons gives the first two', () => {
// Pluto's Horizons page gives no tilt; 119.6 is the one its IAU pole makes with its orbit, so for
// Pluto this checks that its pole and W are drawn as the kernel gives them, not the pole itself.
// The IAU names a planet's north pole by the side of the solar system it lies on, so Venus's W
// and Uranus's run backwards; Pluto's pole follows the right-hand rule instead, and points
// south. Either way the spin read off the drawn sphere is past 90 degrees from the orbit's pole.
expect(drawnObliquity('venus')).toBeCloseTo(177.3, 0);
expect(drawnObliquity('uranus')).toBeCloseTo(97.77, 0);
expect(drawnObliquity('pluto')).toBeCloseTo(119.6, 0);
expect(drawnObliquity('earth')).toBeCloseTo(23.44, 0);
});
/**
* Where on its drawn sphere a body faces a point, as east longitude and latitude on its map: read
* from the texture coordinates where a ray from that point meets the sphere, so the map's own
* convention is part of what is measured.
*/
function facing(id: string, point: THREE.Vector3): { eastDeg: number; latDeg: number } {
const marker = renderer.members.find((member) => member.id === id)!.marker as THREE.Mesh;
const centre = worldPosition(id);
const towards = point.clone().sub(centre).normalize();
const radius = marker.userData['radiusAu'];
const hit = new THREE.Raycaster(centre.clone().addScaledVector(towards, radius * 4), towards.clone().negate()).intersectObject(marker)[0];
return { eastDeg: (hit.uv!.x - 0.5) * 360, latDeg: (hit.uv!.y - 0.5) * 180 };
}
function worldPosition(id: string): THREE.Vector3 {
const marker = renderer.members.find((member) => member.id === id)!.marker;
marker.updateWorldMatrix(true, false);
return marker.getWorldPosition(new THREE.Vector3());
}
/** Degrees between two longitudes, the short way round. */
const apart = (a: number, b: number): number => Math.abs(((((a - b) % 360) + 540) % 360) - 180);
/** Where the IAU puts a body's prime meridian at a TDB date, in the scene. */
function iauPrimeMeridian(id: string, jdTdb: number): THREE.Vector3 {
const { poleRaDeg, poleDecDeg, primeMeridianDeg } = orientationAt(SHIPPED.find((body) => body.id === id)!.rotationalElements!, jdTdb);
const w = (primeMeridianDeg * Math.PI) / 180;
const meridian = laplacePlaneToEquatorial({ x: Math.cos(w), y: Math.sin(w), z: 0 }, { raDeg: poleRaDeg, decDeg: poleDecDeg });
return new THREE.Vector3(meridian.x, meridian.y, meridian.z);
}
/** The drawn sphere's longitude 0 on its equator: +X of the sphere as `SphereGeometry` wraps its map. */
function drawnPrimeMeridian(id: string): THREE.Vector3 {
return new THREE.Vector3(1, 0, 0).applyQuaternion(renderer.members.find((member) => member.id === id)!.marker.quaternion);
}
it('turns Jupiter at AD 1000 by its W at that date’s TT, 1 574 s after the UT the clock names', () => {
// Espenak and Meeus's ΔT for JD 2086307.5, 1 January 1000 in the Julian calendar, where TT - UT was 23 times what it is today: held
// at today's 69 s, Jupiter was drawn 15 degrees short of its W.
const jdUt = 2086307.5;
renderer.update(jdUt);
expect((drawnPrimeMeridian('jupiter').angleTo(iauPrimeMeridian('jupiter', jdUt + 1574.1 / 86400)) * 180) / Math.PI).toBeLessThan(0.01);
});
it('turns Earth by the UT the clock names, which is its turning: at AD 1000 the Sun stands over Horizons’ point', () => {
// Horizons' sub-solar longitude from the Sun (observer quantity 14, TIME_TYPE=UT) on JD 2086455,
// 1.0510 E, is Earth as it was 8.454 minutes before. Turned by the IAU's W at UT + 69.184 s, as it
// was, the drawn face was 2.3 degrees off (2.0 at UT itself); taken at TDB, which turns it ΔT
// (6.6 degrees) further the same way, 8.6.
renderer.update(2086455 - 8.45437443 / 1440);
expect(apart(facing('earth', new THREE.Vector3()).eastDeg, 1.05101)).toBeLessThan(0.15);
});
it('lights Earth where the Sun really stands: within 4 degrees of Greenwich at noon UTC', () => {
// The equation of time is all that separates them: on 1 June 2025 it puts the Sun over 0.53 W,
// and the drawn sphere has it over 0.52 W.
renderer.update(JUNE_1_2025_NOON_UTC);
expect(Math.abs(facing('earth', new THREE.Vector3()).eastDeg)).toBeLessThan(4);
});
// Horizons' sub-Earth latitude on Saturn (observer quantity 14, from Earth's centre), which is
// planetodetic: taken back to planetocentric through the flattening, it is the angle the rings are
// opened to Earth by. Measured: 26.963, 0.075 and -7.764 degrees drawn, against 26.966, 0.042 and
// -7.813.
const SATURN_FLATTENING = 0.09796;
const RING_OPENING: Array<[date: string, jd: number, planetodeticDeg: number]> = [
['16 October 2017, near their widest', 2458042.5, 32.017423],
['23 March 2025, as Earth crossed their plane', 2460757.5, 0.051359],
['24 September 2026, the south face turned to Earth', 2461307.5, -9.571756]
];
const saturnRingMesh = (): THREE.Mesh => renderer.members.find((member) => member.id === 'saturn')!.marker.children[0] as THREE.Mesh;
/** The ring's face normal in the scene, read off its own geometry rather than its transform. */
function ringNormal(ring: THREE.Mesh): THREE.Vector3 {
ring.updateWorldMatrix(true, false);
return new THREE.Vector3().fromBufferAttribute(ring.geometry.attributes['normal'], 0).transformDirection(ring.matrixWorld);
}
for (const [date, jd, planetodeticDeg] of RING_OPENING) {
it(`opens Saturn's rings to Earth as far as Horizons has them on ${date}`, () => {
renderer.update(jd);
const normal = ringNormal(saturnRingMesh());
const toEarth = worldPosition('earth').sub(worldPosition('saturn')).normalize();
const openingDeg = (Math.asin(normal.dot(toEarth)) * 180) / Math.PI;
const expectedDeg = (Math.atan((1 - SATURN_FLATTENING) ** 2 * Math.tan((planetodeticDeg * Math.PI) / 180)) * 180) / Math.PI;
expect(Math.abs(openingDeg - expectedDeg)).toBeLessThan(0.1);
});
}
it('picks Saturn through its rings', () => {
renderer.update(JUNE_1_2025_NOON_UTC);
const ring = saturnRingMesh();
const normal = ringNormal(ring);
const inRingPlane = new THREE.Vector3().fromBufferAttribute(ring.geometry.attributes['position'], 0).transformDirection(ring.matrixWorld);
// Straight down onto the B ring, 100 000 km out: nowhere near the planet itself.
const onRing = worldPosition('saturn').addScaledVector(inRingPlane, 100000 / 149597870.7);
const [hit] = new THREE.Raycaster(onRing.clone().addScaledVector(normal, 0.01), normal.clone().negate()).intersectObjects(renderer.pickableObjects);
expect(hit.object).toBe(ring);
expect(renderer.memberForObject(hit.object)?.id).toBe('saturn');
});
// Horizons' observer quantities 14 and 15 at 2025-06-01 12:00 UTC, from Earth's centre (from the
// Sun's, for Earth): the sub-observer and sub-solar longitude and latitude, east-positive for
// Earth and the Moon and west-positive for Mars and Jupiter, as each is printed. Horizons gives
// each body as it was when the light now arriving left it, so it is drawn that much earlier. Its
// latitudes are planetodetic, on the body's flattened figure, which a sphere does not have, so the
// drawn latitude is put on that figure before they are compared: without it they differ by what
// the flattening makes of them, 0.14 degrees on Earth, 0.26 on Mars and 0.33 on Jupiter.
//
// Measured: every longitude within 0.09 degrees and every latitude within 0.03, but for the
// Moon's face towards Earth, 0.70 and 0.09 out because its mean orbit is (its evection alone is
// 1.27 degrees); its face towards the Sun is within 0.002. Io's face towards Jupiter is 0.012 out:
// with its orbit taken at the clock's UTC and its spin at TDB it was 0.175, the 69 s between them.
const SUB_POINTS: Array<[id: string, observer: string | undefined, lightMinutes: number, west: boolean, flattening: number, observerLon: number, observerLat: number, sunLon: number, sunLat: number, maxObserverDeg: number]> = [
['earth', undefined, 8.43351424, false, 1 / 298.257, 1.5855, 22.261204, 1.579501, 22.260426, 0.1],
['mars', 'earth', 14.13295841, true, 1 - 3376.2 / 3396.19, 307.365389, 21.27653, 269.287887, 25.451264, 0.1],
['moon', 'earth', 0.02150549, false, 0, 7.256763, -3.462104, 116.285934, 1.503004, 0.8],
['jupiter', 'earth', 50.70337676, true, 1 - 66854 / 71492, 251.139846, 2.58787, 247.855871, 2.572658, 0.1],
['io', 'jupiter', 0.02340584, true, 0, 359.964094, -0.002537, 355.673108, 2.26528, 0.05]
];
for (const [id, observer, lightMinutes, west, flattening, observerLon, observerLat, sunLon, sunLat, maxObserverDeg] of SUB_POINTS) {
it(`faces ${observer ?? 'the Sun'} and the Sun with the points Horizons gives on ${id}`, () => {
renderer.update(JUNE_1_2025_NOON_UTC - lightMinutes / 1440);
const seen = facing(id, observer ? worldPosition(observer) : new THREE.Vector3());
const lit = facing(id, new THREE.Vector3());
const east = (longitude: number): number => (west ? -longitude : longitude);
const planetodetic = (latDeg: number): number => (Math.atan(Math.tan((latDeg * Math.PI) / 180) / (1 - flattening) ** 2) * 180) / Math.PI;
expect(apart(seen.eastDeg, east(observerLon))).toBeLessThan(maxObserverDeg);
expect(Math.abs(planetodetic(seen.latDeg) - observerLat)).toBeLessThan(maxObserverDeg);
expect(apart(lit.eastDeg, east(sunLon))).toBeLessThan(0.1);
expect(Math.abs(planetodetic(lit.latDeg) - sunLat)).toBeLessThan(0.05);
});
}
});
describe('locked moons across the clock’s window', () => {
// As shipped, pole, W and all: the IAU gives each a W fitted near the present, and its rate is
// not quite its orbit's, nor Iapetus's pole a line for twenty centuries.
const shipped: BodyRecord[] = JSON.parse(readFileSync(`${process.cwd()}/src/assets/data/bodies.json`, 'utf8'));
const renderer = new SystemOrbitsRenderer(
shipped.filter((body) => ['jupiter', 'saturn', 'uranus', 'neptune', 'europa', 'ganymede', 'callisto', 'mimas', 'rhea', 'iapetus', 'miranda', 'triton', 'proteus'].includes(body.id)),
[]
);
/** Degrees between two lines, the way a spin axis and an orbit normal are compared: Miranda turns backwards against the IAU's pole. */
function linesApartDeg(a: THREE.Vector3, b: THREE.Vector3): number {
return (Math.acos(Math.min(1, Math.abs(a.clone().normalize().dot(b.clone().normalize())))) * 180) / Math.PI;
}
/** A moon's drawn spin axis and the normal of its drawn orbit line, in the scene's ICRF frame. */
function axisAndOrbitNormal(id: string): { axis: THREE.Vector3; normal: THREE.Vector3 } {
const moon = renderer.members.find((member) => member.id === id)!.marker;
const line = moon.parent!.children.find((child) => child.name === 'orbit-line')!;
return { axis: new THREE.Vector3(0, 1, 0).applyQuaternion(moon.quaternion), normal: new THREE.Vector3(0, 0, 1).applyQuaternion(line.quaternion) };
}
/** East longitude, on its map, of the point on a moon's drawn sphere that faces its planet. */
function facingPlanet(id: string): number {
const moon = renderer.members.find((member) => member.id === id)!.marker;
// Its position is from the planet, which is its pivot; SphereGeometry wraps u = atan2(z, -x) / 2 pi.
const toPlanet = moon.position.clone().negate().applyQuaternion(moon.quaternion.clone().invert());
const u = Math.atan2(toPlanet.z, -toPlanet.x) / (2 * Math.PI);
return ((((u - 0.5) * 360) % 360) + 540) % 360 - 180;
}
it('keeps Proteus, Miranda, Mimas and Iapetus facing their planets at AD 1 and AD 3000', () => {
// Measured: Proteus 2.6 degrees at most over AD 1-3000, Miranda 2.4, Mimas 8.9, Iapetus 16 (9.4
// of it the lag of the row its orbit is drawn from). On the IAU's own W and Iapetus's straight
// pole they were 146, 23, 49 and 87 degrees at AD 1.
for (const jd of [1721425.5, 2816787.4]) {
renderer.update(jd);
expect(Math.abs(facingPlanet('proteus'))).toBeLessThan(3);
expect(Math.abs(facingPlanet('miranda'))).toBeLessThan(3);
expect(Math.abs(facingPlanet('mimas'))).toBeLessThan(9.5);
expect(Math.abs(facingPlanet('iapetus'))).toBeLessThan(16.5);
}
});
it('keeps the axes of Mimas and Iapetus on their drawn orbits’ normals, as a Cassini state holds them, at AD 1, today and AD 3000', () => {
// Measured over AD 1-3000: Mimas 0.44 degrees at most, Iapetus 0.74. With Iapetus's pole on
// its Laplace pole, 8.3 off at every date.
for (const jd of [1721425.5, 2460676.5, 2816787.4]) {
renderer.update(jd);
for (const id of ['mimas', 'iapetus']) {
const { axis, normal } = axisAndOrbitNormal(id);
expect(linesApartDeg(axis, normal)).toBeLessThan(1);
}
}
});
it('turns the poles of Europa, Ganymede, Callisto, Rhea, Miranda and Triton round with their drawn nodes, at AD 1, today and AD 3000', () => {
// Each pole goes round on a term of its node's angle, re-rated to the node's drawn rate (see
// `lockedToOrbit`), each node at JPL's current rate. Measured at these dates: at most 0.23
// degrees (Miranda). On the IAU's rates Rhea is 0.73, Miranda 0.51 and Triton 0.42, and on the
// archived table's node periods Callisto 0.48 and Miranda 0.42.
for (const jd of [1721425.5, 2460676.5, 2816787.4]) {
renderer.update(jd);
for (const id of ['europa', 'ganymede', 'callisto', 'rhea', 'miranda', 'triton']) {
const { axis, normal } = axisAndOrbitNormal(id);
expect(linesApartDeg(axis, normal), id).toBeLessThan(0.25);
}
}
});
it('draws Miranda’s orbit, and turns its axis, where Horizons has its orbit in 1601 and 2390', () => {
// Horizons' osculating orbit normal (ura184, ICRF), averaged over three of Miranda's orbits about
// each date; it wobbles 0.01 degrees about that. The drawn node turns at JPL's current 17.787-year
// period (see `nodePeriodYears` in the ETL); on the archived table's 17.727, which the IAU's pole
// was once turned after too, the drawn orbit was 2.1 degrees from Horizons' at both dates and the
// axis 2.4 at 1601. A date this far back is TDB less some two minutes; the node moves 0.004 degrees in that.
const HORIZONS_NORMALS: Array<[jd: number, raDeg: number, decDeg: number]> = [
[2305813.5, 72.83137, 16.17526],
[2594102.5, 81.27691, 17.43782]
];
for (const [jd, raDeg, decDeg] of HORIZONS_NORMALS) {
renderer.update(jd);
const [ra, dec] = [(raDeg * Math.PI) / 180, (decDeg * Math.PI) / 180];
const horizons = new THREE.Vector3(Math.cos(dec) * Math.cos(ra), Math.cos(dec) * Math.sin(ra), Math.sin(dec));
const { axis, normal } = axisAndOrbitNormal('miranda');
expect(linesApartDeg(normal, horizons)).toBeLessThan(0.5);
expect(linesApartDeg(axis, horizons)).toBeLessThan(0.5);
}
});
});
@@ -1,12 +1,14 @@
import * as THREE from 'three/webgpu'; 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 { 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 { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler'; import { bodyTexturePath, loadCachedTexture, saturnRing } from '../../shared/rendering/texture-catalog';
import { isPropagatableOrbit, keplerRates, meanElementsAt, orbitEllipsePoints, positionAtEpoch, 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 { tdbFromUtc } from '../../shared/astro/constants';
import { BodyRecord, MeanElementRates, OrbitalElements, RotationalElements } from '../../shared/models/body.model';
import { bodyOrientation, poleFrame } from '../../shared/rendering/body-orientation';
import { bodyMarkerRadiusAu, systemGridRingsAu } from './system-framing'; import { bodyMarkerRadiusAu, systemGridRingsAu } from './system-framing';
import { PolarGridPlane, TetherField } from './grid-plane'; import { PolarGridPlane, TetherField } from './grid-plane';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
@@ -18,6 +20,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);
@@ -42,11 +46,38 @@ const SYSTEM_TETHER_OPACITY = 0.3;
/** /**
* Rotation carrying the **ecliptic** frame into the scene's equatorial one — a turn of the * Rotation carrying the **ecliptic** frame into the scene's equatorial one — a turn of the
* obliquity about the shared vernal-equinox axis. Solar-system elements come from Horizons * obliquity about the shared vernal-equinox axis. The planets' and the Moon's mean elements are
* against the ecliptic, so this is their frame. * given against the J2000 ecliptic, so this is their frame.
*/ */
const ECLIPTIC_FRAME = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), OBLIQUITY_J2000_DEG * DEG_TO_RAD); const ECLIPTIC_FRAME = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), OBLIQUITY_J2000_DEG * DEG_TO_RAD);
/**
* Rotation carrying a moon's element frame into the scene: its local Laplace plane where JPL
* gives one, the ecliptic otherwise. {@link poleFrame} builds it from the axes
* `laplacePlaneToEquatorial` sends, so the scene and the ETL's check against Horizons share the
* one conversion.
*/
function moonFrame(body: BodyRecord): THREE.Quaternion {
return body.laplacePole ? poleFrame(body.laplacePole) : ECLIPTIC_FRAME.clone();
}
const X_AXIS = new THREE.Vector3(1, 0, 0);
const Z_AXIS = new THREE.Vector3(0, 0, 1);
const scratchTurn = new THREE.Quaternion();
/**
* Sets `target` to the rotation carrying an orbit's own plane, periapsis along +X, into the
* scene: the argument of periapsis, then the inclination, then the node, as
* `positionAtTrueAnomaly` turns a point, and then the frame the elements are measured in.
*/
function orientOrbit(target: THREE.Quaternion, elements: OrbitalElements, frame: THREE.Quaternion): THREE.Quaternion {
return target
.copy(frame)
.multiply(scratchTurn.setFromAxisAngle(Z_AXIS, elements.longitudeOfAscendingNodeDeg * DEG_TO_RAD))
.multiply(scratchTurn.setFromAxisAngle(X_AXIS, elements.inclinationDeg * DEG_TO_RAD))
.multiply(scratchTurn.setFromAxisAngle(Z_AXIS, elements.argumentOfPeriapsisDeg * DEG_TO_RAD));
}
/** /**
* Rotation carrying the frame an **exoplanet's** elements are measured in into the scene. * Rotation carrying the frame an **exoplanet's** elements are measured in into the scene.
* *
@@ -88,21 +119,21 @@ function colorForKind(kind: SystemMemberKind): THREE.Color {
} }
} }
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame: THREE.Quaternion): THREE.Line { /** Marks orbit lines so the whole layer can be toggled without touching the bodies. */
const points = orbitEllipsePoints(elements); const ORBIT_LINE_NAME = 'orbit-line';
const positions = new Float32Array(points.length * 3);
const scratch = new THREE.Vector3();
points.forEach((point, index) => {
// Elements are measured against their source's own reference plane; `frame` rotates that
// plane into the scene's equatorial one.
const { x, y, z } = scratch.set(point.x, point.y, point.z).applyQuaternion(frame);
positions[index * 3] = x;
positions[index * 3 + 1] = y;
positions[index * 3 + 2] = z;
});
/**
* The orbit's ellipse, drawn in its own plane and turned into place by the line's quaternion (see
* {@link orientOrbit}), which `update` sets again each tick: a node and a periapsis that turn cost
* a quaternion rather than a new geometry. The Moon's node goes right round in 18.6 years, so an
* ellipse fixed at one date has the Moon up to 2 sin 5.16° of its distance, 69 000 km, off its own
* line nine years on.
*
* The shape is redrawn by {@link reshapeOrbitLine} as the planets' axes and eccentricities drift.
*/
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame: THREE.Quaternion): THREE.Line {
const geometry = new THREE.BufferGeometry(); const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('position', new THREE.BufferAttribute(ellipseInItsPlane(elements, new Float32Array((ORBIT_LINE_SEGMENTS + 1) * 3)), 3));
const material = new THREE.LineBasicMaterial({ const material = new THREE.LineBasicMaterial({
color: colorForKind(kind), color: colorForKind(kind),
@@ -110,45 +141,216 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame
opacity: ORBIT_LINE_OPACITY_BY_KIND[kind] opacity: ORBIT_LINE_OPACITY_BY_KIND[kind]
}); });
return new THREE.Line(geometry, material); const line = new THREE.Line(geometry, material);
line.name = ORBIT_LINE_NAME;
line.userData = { semiMajorAxisAu: elements.semiMajorAxisAu, eccentricity: elements.eccentricity };
orientOrbit(line.quaternion, elements, frame);
return line;
}
const ORBIT_LINE_SEGMENTS = 128;
/** The orbit's ellipse in its own plane, periapsis along +X, written into `positions`. */
function ellipseInItsPlane(elements: OrbitalElements, positions: Float32Array): Float32Array {
orbitEllipsePoints({ ...elements, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0 }, ORBIT_LINE_SEGMENTS).forEach((point, index) => {
positions[index * 3] = point.x;
positions[index * 3 + 1] = point.y;
positions[index * 3 + 2] = point.z;
});
return positions;
} }
/** /**
* A marker sphere, surfaced with the body's own derived appearance rather than a flat category * How far, in AU, an orbit's drawn ellipse may be from its current one before it is drawn again:
* colour — so a system reads as a set of distinct worlds at a glance, and the colour of each is * well under the 128 chords' own sag from the true curve, at most 0.00055 AU for Mars and 0.0032
* a consequence of its measurements rather than of which list it came from. * for Saturn, near aphelion, where points spaced evenly in true anomaly lie furthest apart.
*
* The texture is tiny (see `MARKER_TEXTURE_WIDTH`): a marker is a few pixels across, so what
* survives is essentially its average colour, and generating it costs well under a millisecond.
*/ */
function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined, systemSpanAu: number, appearance: PlanetAppearance | undefined): THREE.Mesh { const ORBIT_RESHAPE_AU = 1e-4;
const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm, systemSpanAu), 16, 12);
const material = appearance /**
? new THREE.MeshBasicMaterial({ map: planetTexture(appearance, { width: MARKER_TEXTURE_WIDTH, height: MARKER_TEXTURE_HEIGHT }) }) * Draws an orbit line's ellipse again once the axis and eccentricity it was drawn with have drifted
: new THREE.MeshBasicMaterial({ color: colorForKind(kind) }); * from `elements`' by more than {@link ORBIT_RESHAPE_AU}. Standish's rates move Saturn's
return new THREE.Mesh(geometry, material); * eccentricity 0.0064 in twenty centuries, and left at J2000's, the line passed 0.056 AU, 8.4
* million km, from Saturn at AD 1; Jupiter 0.016 AU there, Pluto 0.021 at AD 3000. The moons' and
* the exoplanets' elements carry no such rates, so their lines are drawn once.
*/
function reshapeOrbitLine(line: THREE.Line, elements: OrbitalElements): void {
const drawn = line.userData as { semiMajorAxisAu: number; eccentricity: number };
const driftAu = Math.abs(elements.semiMajorAxisAu - drawn.semiMajorAxisAu) + elements.semiMajorAxisAu * Math.abs(elements.eccentricity - drawn.eccentricity);
if (driftAu <= ORBIT_RESHAPE_AU) {
return;
}
const position = line.geometry.getAttribute('position') as THREE.BufferAttribute;
ellipseInItsPlane(elements, position.array as Float32Array);
position.needsUpdate = true;
line.geometry.computeBoundingSphere();
line.userData = { semiMajorAxisAu: elements.semiMajorAxisAu, eccentricity: elements.eccentricity };
}
/**
* A marker sphere, surfaced with the body's own photograph where one has ever been taken, and
* with a texture derived from its measurements where none has — and lit by its star either way,
* so a world shows the day and night it actually has.
*
* The photographs were already in the repository, used only by the detail page: the system view
* 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.
*
* A derived texture is not painted here but handed to `deferSurface`, which paints it after the
* system is built: at about 4.4 ms each, the twenty bodies the solar system gained with its dwarf
* planets and smaller moons lengthened the task that enters it from 78-94 ms to 177-228. Until
* then the body is its kind's flat colour.
*
* A photograph is handed to `deferPhotograph`, which puts it on the body once it has loaded, one a
* frame: a texture is copied to the GPU in the first frame that draws it, and the 28 maps, which
* arrive within 40 ms of each other, made that one frame a 160-210 ms task on entering the Sun's
* system (copyExternalImageToTexture, about 38 megapixels of JPEG: nine maps at 2048 by 1024, the
* Sun's among them, Jupiter's at 3840 by 1920, and eighteen smaller).
*
* Every marker is the one unit sphere, {@link MARKER_SPHERE}, scaled to the body's radius, which
* it also keeps as `userData.radiusAu`: built one a body, the 38 spheres of the Sun's system took
* 12 ms of the 15 ms the renderer took to build and, with their upload, made a return to the
* system a long task of 52 to 70 ms, where the 18 bodies before had made none.
*/
function buildMarker(
id: string | undefined,
kind: SystemMemberKind,
radiusKm: number | undefined,
appearance: PlanetAppearance | undefined,
deferSurface: (paint: () => void) => void,
deferPhotograph: (material: THREE.MeshStandardMaterial, texture: THREE.Texture) => void
): THREE.Mesh {
const photograph = id ? bodyTexturePath(id) : undefined;
// null, not undefined, until there is one: three warns "parameter 'map' has value of
// undefined" for every body built so, eleven of them on entering the Sun's system.
const material = new THREE.MeshStandardMaterial({
map: null,
color: colorForKind(kind),
roughness: 1,
metalness: 0
});
if (photograph) {
deferPhotograph(material, loadCachedTexture(photograph));
} else if (appearance) {
deferSurface(() => {
// 128 by 64, not the detail page's 512 by 256: that size costs about 60 ms a body on the
// main thread, for a disc that is a few pixels across until the camera is on top of it.
material.map = planetTexture(appearance, { width: 128, height: 64 });
material.color.set(0xffffff);
material.needsUpdate = true;
});
}
const marker = new THREE.Mesh(MARKER_SPHERE, material);
const radiusAu = bodyMarkerRadiusAu(radiusKm);
marker.scale.setScalar(radiusAu);
marker.userData = { radiusAu };
return marker;
}
/**
* 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;
/** Shared by every marker of every system, so it is never disposed; see `buildMarker`. */
const MARKER_SPHERE = new THREE.SphereGeometry(1, MARKER_WIDTH_SEGMENTS, MARKER_HEIGHT_SEGMENTS);
/**
* 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 the IAU gives no rotational elements for is turned at a given date — Eris, Haumea,
* Makemake and Nereid, whose periods are measured (Makemake's only to a factor of two, see its
* spec in `fetchSolarSystem.ts`) and whose poles are not: at its own sidereal rate, about
* its orbit's normal, backwards for a negative period. None of them has an obliquity, so none is
* applied. The phase is arbitrary: each body starts at its elements' epoch in the shortest
* rotation of +Y onto its axis, and turns from there. Exoplanets have no published rotation at
* all, and are left still.
*
* Every other body is turned by {@link bodyOrientation}.
*/
function spinFor(elements: OrbitalElements, frame: THREE.Quaternion, rotationPeriodHours: number, daysSinceEpoch: number): THREE.Quaternion {
const node = elements.longitudeOfAscendingNodeDeg * DEG_TO_RAD;
const inclination = elements.inclinationDeg * DEG_TO_RAD;
const axis = new THREE.Vector3(Math.sin(inclination) * Math.sin(node), -Math.sin(inclination) * Math.cos(node), Math.cos(inclination)).applyQuaternion(frame);
const turns = (daysSinceEpoch * HOURS_PER_DAY) / rotationPeriodHours;
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;
elements: OrbitalElements; elements: OrbitalElements;
gmAu3PerDay2: number; rates: MeanElementRates;
marker: THREE.Mesh; marker: THREE.Mesh;
orbitLine: THREE.Line;
/** Rotation from this body's own element frame into the scene's equatorial one. */ /** Rotation from this body's own element frame into the scene's equatorial one. */
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;
rotationalElements?: RotationalElements;
} }
interface TrackedMoon { interface TrackedMoon {
id: string; id: string;
elements: OrbitalElements; elements: OrbitalElements;
gmAu3PerDay2: number; rates: MeanElementRates;
marker: THREE.Mesh; marker: THREE.Mesh;
orbitLine: THREE.Line;
frame: THREE.Quaternion; frame: THREE.Quaternion;
pivot: THREE.Group; pivot: THREE.Group;
parentId: string; parentId: string;
rotationPeriodHours?: number;
rotationalElements?: RotationalElements;
/**
* Where the moon and its planet go round a barycentre outside the planet (Charon): the moon's
* mass over the planet's, and the planet's own small orbit round that point.
*/
barycentre?: { massRatio: number; parentOrbitLine: THREE.Line };
} }
/** /**
@@ -170,10 +372,12 @@ export class SystemOrbitsRenderer {
*/ */
readonly referenceFrame: THREE.Quaternion; readonly referenceFrame: THREE.Quaternion;
/** /**
* Outer radius (AU) of the reference grid, or 0 where there is none. This — not the outermost * How far (AU) from the star the system draws anything, or 0 where it draws nothing: what the
* orbit — is the widest thing the system draws, so it is what the camera has to frame. * camera has to frame. The reference grid's outer ring, which runs 15 per cent past the largest
* semi-major axis, unless an eccentric orbit reaches further at its aphelion — Eris's, 97.7 AU,
* does past the solar system's 80 AU ring, and some orbit does in 303 of the 1 190 exoplanet systems.
*/ */
readonly gridOuterRadiusAu: number; readonly outermostRadiusAu: number;
private readonly topLevelBodies: TrackedTopLevelBody[] = []; private readonly topLevelBodies: TrackedTopLevelBody[] = [];
private readonly moons: TrackedMoon[] = []; private readonly moons: TrackedMoon[] = [];
@@ -185,6 +389,22 @@ export class SystemOrbitsRenderer {
* following them each tick costs no allocation at all. * following them each tick costs no allocation at all.
*/ */
private tetherPoints: readonly THREE.Vector3[] = []; private tetherPoints: readonly THREE.Vector3[] = [];
/** Derived surfaces still to paint, one a task, once the constructor is done; see `buildMarker`. */
private readonly surfacesToPaint: Array<() => void> = [];
private surfaceTimer?: ReturnType<typeof setTimeout>;
private readonly deferSurface = (paint: () => void): void => {
this.surfacesToPaint.push(paint);
this.surfaceTimer ??= setTimeout(this.paintNextSurface, 0);
};
private readonly paintNextSurface = (): void => {
this.surfacesToPaint.shift()?.();
this.surfaceTimer = this.surfacesToPaint.length > 0 ? setTimeout(this.paintNextSurface, 0) : undefined;
};
/** Photographs still to put on their bodies, one a frame once loaded; see `buildMarker`. */
private readonly photographsToShow: Array<{ material: THREE.MeshStandardMaterial; texture: THREE.Texture }> = [];
private readonly deferPhotograph = (material: THREE.MeshStandardMaterial, texture: THREE.Texture): void => {
this.photographsToShow.push({ material, texture });
};
constructor( constructor(
bodies: readonly BodyRecord[], bodies: readonly BodyRecord[],
@@ -201,12 +421,11 @@ 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 const topLevelOrbits = [
// the markers are created as the bodies are added. ...bodies.filter((body) => !body.parentBodyId).map(({ orbit }) => ({ axis: orbit.semiMajorAxisAu, eccentricity: orbit.eccentricity })),
const topLevelAxes = [ ...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map(({ orbit }) => ({ axis: orbit.semiMajorAxisAu!, eccentricity: orbit.eccentricity ?? 0 }))
...bodies.filter((body) => !body.parentBodyId).map((body) => body.orbit.semiMajorAxisAu), ].filter(({ axis }) => Number.isFinite(axis) && axis > 0);
...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map((exoplanet) => exoplanet.orbit.semiMajorAxisAu!) const topLevelAxes = topLevelOrbits.map(({ axis }) => axis);
].filter((axis) => Number.isFinite(axis) && axis > 0);
this.maxTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.max(...topLevelAxes) : 0; this.maxTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.max(...topLevelAxes) : 0;
this.minTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.min(...topLevelAxes) : 0; this.minTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.min(...topLevelAxes) : 0;
@@ -222,7 +441,16 @@ 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, body.rates, body.radiusKm, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar), { periodHours: body.rotationPeriodHours, elements: body.rotationalElements });
if (body.id === 'saturn') {
// A child of the sphere, so it lies in the equator the IAU pole turns the sphere into and
// is scaled with it where the marker is held to its pixel floor. Jupiter's, Uranus's and
// Neptune's rings are left out: dark, narrow or dusty, they are too faint to see here.
// In the sphere's own units, its radius being 1.
const ring = saturnRing(body.radiusKm, 1);
tracked.marker.add(ring);
this.trackDisposable(ring.geometry, ring.material as THREE.Material);
}
members.push({ id: body.id, kind, marker: tracked.marker }); members.push({ id: body.id, kind, marker: tracked.marker });
} }
@@ -235,8 +463,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, body.rates, body.radiusKm, parentTracked, moonFrame(body), appearanceForBody(body, bodies, hostLuminositySolar), { periodHours: body.rotationPeriodHours, elements: body.rotationalElements }, body.massRatio);
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.
@@ -251,27 +479,28 @@ 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);
// Not `gmForParent(undefined)`: that assumes a solar-mass host for every system, and const radiusKm = radiusEarth ? radiusEarth * EARTH_RADIUS_KM : undefined;
// most exoplanet hosts are red dwarfs a fraction of the Sun's mass. // Not the Sun's: that assumes a solar-mass host for every system, and most exoplanet hosts
// are red dwarfs a fraction of the Sun's mass.
const gm = resolveGravitationalParameter({ const gm = resolveGravitationalParameter({
semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu, semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu,
periodDays: exoplanet.periodDays, periodDays: exoplanet.periodDays,
hostStarMassSolar: exoplanet.hostStarMassSolar hostStarMassSolar: exoplanet.hostStarMassSolar
}); });
const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, gm, radiusKm, exoplanetFrame, appearanceForExoplanet(exoplanet, hostLuminositySolar)); const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, keplerRates(elements.semiMajorAxisAu, gm), radiusKm, exoplanetFrame, appearanceForExoplanet(exoplanet, hostLuminositySolar));
members.push({ id: exoplanet.id, kind: 'exoplanet', marker: tracked.marker }); members.push({ id: exoplanet.id, kind: 'exoplanet', marker: tracked.marker });
} }
this.members = members; this.members = members;
// Which plane the system is read against follows from where its elements came from. Only the // Which plane the system is read against follows from where its elements came from. Only the
// Sun has Horizons bodies and no system has both, so this is a choice between the two rather // Sun has JPL bodies and no system has both, so this is a choice between the two rather
// than a compromise: the ecliptic if there are solar-system bodies, the sky plane otherwise. // than a compromise: the ecliptic if there are solar-system bodies, the sky plane otherwise.
this.referenceFrame = bodies.some((body) => !body.parentBodyId) ? ECLIPTIC_FRAME.clone() : exoplanetFrame; this.referenceFrame = bodies.some((body) => !body.parentBodyId) ? ECLIPTIC_FRAME.clone() : exoplanetFrame;
const rings = systemGridRingsAu(this.maxTopLevelSemiMajorAxisAu); const rings = systemGridRingsAu(this.maxTopLevelSemiMajorAxisAu);
this.gridOuterRadiusAu = rings.length > 0 ? rings[rings.length - 1] : 0; this.outermostRadiusAu = Math.max(rings.length > 0 ? rings[rings.length - 1] : 0, ...topLevelOrbits.map(({ axis, eccentricity }) => axis * (1 + eccentricity)));
if (rings.length > 0) { if (rings.length > 0) {
this.grid = new PolarGridPlane({ this.grid = new PolarGridPlane({
ringRadii: rings, ringRadii: rings,
@@ -294,14 +523,30 @@ 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, UTC as the map's clock gives it:
* the orbits are taken at its TDB, as the spins are. Call once per tick.
*/
update(epochJd: number): void { update(epochJd: number): void {
this.showNextPhotograph();
const jdTdb = tdbFromUtc(epochJd);
for (const body of this.topLevelBodies) { for (const body of this.topLevelBodies) {
const orbital = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd); const current = meanElementsAt(body.elements, body.rates, jdTdb);
const orbital = positionAtEpoch(current);
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);
orientOrbit(body.orbitLine.quaternion, current, body.frame);
reshapeOrbitLine(body.orbitLine, current);
if (body.rotationalElements) {
bodyOrientation(body.rotationalElements, epochJd, body.marker.quaternion, body.id === 'earth');
} else if (body.rotationPeriodHours) {
body.marker.quaternion.copy(spinFor(current, body.frame, body.rotationPeriodHours, jdTdb - body.elements.epochJd));
}
} }
for (const moon of this.moons) { for (const moon of this.moons) {
@@ -310,8 +555,23 @@ export class SystemOrbitsRenderer {
continue; continue;
} }
moon.pivot.position.copy(parent.position); moon.pivot.position.copy(parent.position);
const orbital = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd); const current = meanElementsAt(moon.elements, moon.rates, jdTdb);
const orbital = positionAtEpoch(current);
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);
orientOrbit(moon.orbitLine.quaternion, current, moon.frame);
if (moon.barycentre) {
// The planet's elements place the pair's barycentre, which is where the pivot is: the
// planet sits the moon's share of their separation back from it, the moon the rest out.
const { massRatio, parentOrbitLine } = moon.barycentre;
parent.marker.position.copy(parent.position).addScaledVector(moon.marker.position, -massRatio / (1 + massRatio));
moon.marker.position.multiplyScalar(1 / (1 + massRatio));
parentOrbitLine.quaternion.copy(moon.orbitLine.quaternion);
}
if (moon.rotationalElements) {
bodyOrientation(moon.rotationalElements, epochJd, moon.marker.quaternion);
} else if (moon.rotationPeriodHours) {
moon.marker.quaternion.copy(spinFor(current, moon.frame, moon.rotationPeriodHours, jdTdb - moon.elements.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
@@ -319,9 +579,24 @@ export class SystemOrbitsRenderer {
this.tethers?.setTargets(this.tetherPoints); this.tethers?.setTargets(this.tetherPoints);
} }
/** Looks up which system member a marker object belongs to (e.g. from a raycast hit). */ /** Puts the first photograph that has loaded on its body: one texture for the GPU a frame. */
private showNextPhotograph(): void {
const index = this.photographsToShow.findIndex(({ texture }) => texture.image);
if (index < 0) {
return;
}
const [{ material, texture }] = this.photographsToShow.splice(index, 1);
material.map = texture;
material.color.set(0xffffff);
material.needsUpdate = true;
}
/**
* Looks up which system member a marker object belongs to (e.g. from a raycast hit), or a part
* of one: a ray through Saturn's rings picks Saturn.
*/
memberForObject(object: THREE.Object3D): SystemMember | undefined { memberForObject(object: THREE.Object3D): SystemMember | undefined {
return this.members.find((member) => member.marker === object); return this.members.find((member) => member.marker === object || member.marker === object.parent);
} }
/** All marker objects, for raycasting. */ /** All marker objects, for raycasting. */
@@ -329,11 +604,31 @@ export class SystemOrbitsRenderer {
return this.members.map((member) => member.marker); return this.members.map((member) => member.marker);
} }
/** Shows or hides the orbit lines and the reference grid, leaving the bodies themselves. */
setLayerVisibility(layers: { orbits: boolean; grid: boolean }): void {
this.object.traverse((child) => {
if (child.name === ORBIT_LINE_NAME) {
child.visible = layers.orbits;
}
});
if (this.grid) {
this.grid.object.visible = layers.grid;
}
if (this.tethers) {
this.tethers.object.visible = layers.grid;
}
}
dispose(): void { dispose(): void {
clearTimeout(this.surfaceTimer);
this.surfacesToPaint.length = 0;
this.photographsToShow.length = 0;
this.grid?.dispose(); this.grid?.dispose();
this.tethers?.dispose(); this.tethers?.dispose();
for (const { geometry, material } of this.disposables) { for (const { geometry, material } of this.disposables) {
if (geometry !== MARKER_SPHERE) {
geometry.dispose(); geometry.dispose();
}
material.dispose(); material.dispose();
} }
// Detach as well as dispose. A star-to-star hop builds a new renderer and drops the old // Detach as well as dispose. A star-to-star hop builds a new renderer and drops the old
@@ -348,18 +643,19 @@ export class SystemOrbitsRenderer {
id: string, id: string,
kind: SystemMemberKind, kind: SystemMemberKind,
elements: OrbitalElements, elements: OrbitalElements,
gmAu3PerDay2: number, rates: MeanElementRates,
radiusKm: number | undefined, radiusKm: number | undefined,
frame: THREE.Quaternion, frame: THREE.Quaternion,
appearance?: PlanetAppearance appearance?: PlanetAppearance,
rotation?: { periodHours?: number; elements?: RotationalElements }
): 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.deferSurface, this.deferPhotograph);
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, rates, marker, orbitLine, frame, position: new THREE.Vector3(), rotationPeriodHours: rotation?.periodHours, rotationalElements: rotation?.elements };
this.topLevelBodies.push(tracked); this.topLevelBodies.push(tracked);
return tracked; return tracked;
} }
@@ -367,21 +663,36 @@ export class SystemOrbitsRenderer {
private addMoon( private addMoon(
id: string, id: string,
elements: OrbitalElements, elements: OrbitalElements,
gmAu3PerDay2: number, rates: MeanElementRates,
radiusKm: number | undefined, radiusKm: number | undefined,
parent: TrackedTopLevelBody, parent: TrackedTopLevelBody,
frame: THREE.Quaternion, frame: THREE.Quaternion,
appearance?: PlanetAppearance appearance?: PlanetAppearance,
rotation?: { periodHours?: number; elements?: RotationalElements },
massRatio?: 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, this.deferSurface, this.deferPhotograph);
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 }; let barycentre: TrackedMoon['barycentre'];
if (massRatio !== undefined) {
// Both orbits are the relative one, scaled: the moon's by the planet's share of the mass,
// the planet's by the moon's share and turned half round, since it is always opposite.
// Charon's then spans 17 460 km of radius, Pluto's 2 131, and neither passes through Pluto.
orbitLine.scale.setScalar(1 / (1 + massRatio));
const parentOrbitLine = buildOrbitLine(elements, parent.kind, frame);
parentOrbitLine.scale.setScalar(-massRatio / (1 + massRatio));
pivot.add(parentOrbitLine);
this.trackDisposable(parentOrbitLine.geometry, parentOrbitLine.material as THREE.Material);
barycentre = { massRatio, parentOrbitLine };
}
const moon: TrackedMoon = { id, elements, rates, marker, orbitLine, frame, pivot, parentId: parent.id, rotationPeriodHours: rotation?.periodHours, rotationalElements: rotation?.elements, barycentre };
this.moons.push(moon); this.moons.push(moon);
return moon; return moon;
} }
@@ -0,0 +1,552 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DataLoaderService } from '../../core/data/data-loader.service';
import { BookmarksStore } from '../../shared/state/bookmarks.store';
import { TimeStore } from '../../shared/state/time.store';
import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent } from './hud-dock.component';
// jsdom has no matchMedia, which the dock reads once, at import: this one answers from `viewport`.
const viewport = vi.hoisted(() => {
const state = { wide: true };
window.matchMedia = (() => ({ get matches() { return state.wide; } })) as unknown as typeof window.matchMedia;
return state;
});
class EmptyDataLoaderService {
loadStars() {
return Promise.resolve({ stars: [], positions: new Float32Array(0) });
}
loadBodies() {
return Promise.resolve([]);
}
loadExoplanets() {
return Promise.resolve([]);
}
}
describe('HudDockComponent', () => {
let fixture: ComponentFixture<HudDockComponent>;
function host(): HTMLElement {
return fixture.nativeElement as HTMLElement;
}
function tabNames(): string[] {
return [...host().querySelectorAll('[role="tab"]')].map((tab) => tab.textContent?.trim() ?? '');
}
function tab(name: string): HTMLButtonElement {
const found = [...host().querySelectorAll<HTMLButtonElement>('[role="tab"]')].find((t) => t.textContent?.trim() === name);
if (!found) {
throw new Error(`no "${name}" tab`);
}
return found;
}
function setReadout(): void {
fixture.componentRef.setInput('eyebrow', 'Galactic Scale');
fixture.componentRef.setInput('title', 'Milky Way');
fixture.componentRef.setInput('subtitle', 'Barred spiral');
fixture.componentRef.setInput('readouts', [{ label: 'Arms', value: '5' }, { label: 'Luminosity', value: '1 L☉', derived: true }]);
fixture.componentRef.setInput('note', 'Illustrative model.');
fixture.componentRef.setInput('range', '21.5 kpc');
}
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [HudDockComponent],
providers: [
{ provide: DataLoaderService, useClass: EmptyDataLoaderService },
{ provide: Router, useValue: { navigate: vi.fn().mockResolvedValue(true) } }
]
}).compileComponents();
fixture = TestBed.createComponent(HudDockComponent);
});
it('offers the search and what has been kept, when it has nothing else', () => {
// Bookmarks are always offered: it is the only place that says the map can keep anything.
fixture.detectChanges();
expect(tabNames()).toEqual(['Search', 'Bookmarks']);
expect(host().querySelector('[role="tabpanel"]')).toBeNull();
});
it('grows a tab per thing it has been given', () => {
setReadout();
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
fixture.detectChanges();
expect(tabNames()).toEqual(['Search', 'Readout', 'Bookmarks', 'Display']);
fixture.componentRef.setInput('routing', true);
fixture.detectChanges();
expect(tabNames()).toEqual(['Search', 'Readout', 'Routes', 'Bookmarks', 'Display']);
});
it('offers the clock alone, as a Clock tab, to a surface with no layers', () => {
fixture.componentRef.setInput('clock', true);
fixture.componentRef.setInput('defaultTab', 'display');
fixture.detectChanges();
expect(tabNames()).toEqual(['Search', 'Bookmarks', 'Clock']);
const panel = host().querySelector('#dock-panel-display')!;
expect(panel.querySelector('[role="radiogroup"][aria-label="Clock rate"]')).not.toBeNull();
expect(panel.querySelector('#clock-date')).not.toBeNull();
expect(panel.textContent).not.toContain('Layers');
});
it('opens the default tab on mount and renders the readout from its inputs', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
expect(tab('Readout').getAttribute('aria-selected')).toBe('true');
const text = host().textContent ?? '';
for (const expected of ['Galactic Scale', 'Milky Way', 'Barred spiral', 'Arms', '5', 'Illustrative model.', 'Derived, not catalogued', '21.5 kpc']) {
expect(text).toContain(expected);
}
expect(host().querySelector('[data-testid="hud-title"]')?.textContent?.trim()).toBe('Milky Way');
});
it('leaves out the optional lines it was given nothing for', () => {
fixture.componentRef.setInput('title', 'Local Stars');
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
expect(host().querySelector('dl')).toBeNull();
expect(host().textContent).not.toContain('undefined');
});
it('keeps the range on the strip whichever panel is open, and hides it when there is none', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'search');
fixture.detectChanges();
expect(host().textContent).toContain('21.5 kpc');
fixture.componentRef.setInput('range', '');
fixture.detectChanges();
expect(host().textContent).not.toContain('Range');
});
it('toggles a tab closed when it is clicked while open', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
tab('Readout').click();
fixture.detectChanges();
expect(host().querySelector('[role="tabpanel"]')).toBeNull();
expect(tab('Readout').getAttribute('aria-selected')).toBe('false');
tab('Search').click();
fixture.detectChanges();
expect(host().querySelector('input[type="text"]')).not.toBeNull();
});
it('emits the flipped layer and nothing else', () => {
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
fixture.componentRef.setInput('defaultTab', 'display');
fixture.detectChanges();
const emitted: HudDisplay[] = [];
fixture.componentInstance.displayChange.subscribe((display) => emitted.push(display));
const orbits = [...host().querySelectorAll<HTMLButtonElement>('[aria-pressed]')].find((b) => b.textContent?.includes('Orbits'));
orbits?.click();
expect(emitted).toEqual([{ ...DEFAULT_HUD_DISPLAY, orbits: false }]);
});
it('reflects the layer state it is given as pressed buttons', () => {
fixture.componentRef.setInput('display', { ...DEFAULT_HUD_DISPLAY, grid: false });
fixture.componentRef.setInput('defaultTab', 'display');
fixture.detectChanges();
const pressed = [...host().querySelectorAll('[aria-pressed]')].map((b) => `${b.textContent?.trim()}=${b.getAttribute('aria-pressed')}`);
expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true', 'Jump links=false', 'Plan view=false', 'Backwards=false']);
});
it('says how to keep a place, rather than showing an empty list', () => {
fixture.componentRef.setInput('defaultTab', 'bookmarks');
fixture.detectChanges();
expect(host().querySelector('#dock-panel-bookmarks ul')).toBeNull();
expect(host().textContent).toContain('Nothing kept yet');
});
it('lists what has been kept, newest first, and says which kind each is', () => {
const bookmarks = TestBed.inject(BookmarksStore);
bookmarks.toggle({ kind: 'star', id: 7, name: 'Sirius' });
bookmarks.toggle({ kind: 'body', id: 'earth', name: 'Earth' });
fixture.componentRef.setInput('defaultTab', 'bookmarks');
fixture.detectChanges();
const rows = [...host().querySelectorAll('#dock-panel-bookmarks li')].map((li) => li.textContent?.replace(/\s+/g, ' ').trim());
expect(rows[0]).toContain('Earth');
expect(rows[0]).toContain('Body');
expect(rows[1]).toContain('Sirius');
expect(rows[1]).toContain('System');
});
it('hands back the place that was chosen, whole', () => {
const bookmarks = TestBed.inject(BookmarksStore);
bookmarks.toggle({ kind: 'star', id: 7, name: 'Sirius' });
fixture.componentRef.setInput('defaultTab', 'bookmarks');
fixture.detectChanges();
const chosen: unknown[] = [];
fixture.componentInstance.bookmarkChosen.subscribe((bookmark) => chosen.push(bookmark));
host().querySelector<HTMLButtonElement>('#dock-panel-bookmarks li button')?.click();
expect(chosen).toEqual([{ kind: 'star', id: 7, name: 'Sirius' }]);
});
it('forgets one without disturbing the rest', () => {
const bookmarks = TestBed.inject(BookmarksStore);
bookmarks.toggle({ kind: 'star', id: 7, name: 'Sirius' });
bookmarks.toggle({ kind: 'body', id: 'earth', name: 'Earth' });
fixture.componentRef.setInput('defaultTab', 'bookmarks');
fixture.detectChanges();
host().querySelector<HTMLButtonElement>('[aria-label="Forget Earth"]')?.click();
fixture.detectChanges();
expect(bookmarks.bookmarks().map((bookmark) => bookmark.name)).toEqual(['Sirius']);
expect(host().textContent).not.toContain('Earth');
});
it('keeps the star the readout is about, and says so on the control', () => {
const bookmarks = TestBed.inject(BookmarksStore);
setReadout();
fixture.componentRef.setInput('title', 'Sirius');
fixture.componentRef.setInput('keepableStarId', 7);
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
const keep = host().querySelector<HTMLButtonElement>('[aria-label="Keep Sirius"]');
expect(keep?.getAttribute('aria-pressed')).toBe('false');
keep?.click();
fixture.detectChanges();
expect(bookmarks.has('star', 7)).toBe(true);
expect(host().querySelector('[aria-label="Forget Sirius"]')?.getAttribute('aria-pressed')).toBe('true');
});
it('keeps the one system whose catalogue id is zero, which truthiness would have lost', () => {
// The Sun is star 0. A `@if (id; as ...)` reads that as "no star" and hides the control.
const bookmarks = TestBed.inject(BookmarksStore);
setReadout();
fixture.componentRef.setInput('title', 'Sol');
fixture.componentRef.setInput('keepableStarId', 0);
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
host().querySelector<HTMLButtonElement>('[aria-label="Keep Sol"]')?.click();
fixture.detectChanges();
expect(bookmarks.has('star', 0)).toBe(true);
});
it('offers nothing to keep where the readout is a scale rather than a place', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
expect(host().querySelector('[aria-label^="Keep"]')).toBeNull();
});
it('opens the search on "/" from anywhere but a text field', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true }));
fixture.detectChanges();
expect(tab('Search').getAttribute('aria-selected')).toBe('true');
});
it('hands the panel back to the readout once a result is picked', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'search');
fixture.detectChanges();
fixture.componentInstance.onPicked();
fixture.detectChanges();
expect(tab('Readout').getAttribute('aria-selected')).toBe('true');
});
it('will not plot from a departure that was typed but never chosen', () => {
setReadout();
fixture.componentRef.setInput('routing', true);
fixture.componentRef.setInput('currentStar', { id: 3, name: "Barnard's Star", subtitle: '1.8 pc' });
fixture.componentRef.setInput('routeOptions', [{ id: 7, name: 'Sirius', subtitle: '2.6 pc' }]);
fixture.componentRef.setInput('defaultTab', 'routes');
fixture.detectChanges();
const plot = () => [...host().querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.includes('Plot route'))!;
const type = (field: string, value: string) => {
const input = host().querySelector<HTMLInputElement>(`#route-${field}`)!;
input.value = value;
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
};
type('to', 'Sir');
host().querySelector<HTMLButtonElement>('#dock-panel-routes ul button')!.click();
fixture.detectChanges();
// With the departure field empty, the view's own star stands in for it.
expect(plot().disabled).toBe(false);
// Text that names no chosen star is not a departure: plotting from the view's star instead
// would name one place and leave from another.
type('from', 'Sol');
expect(plot().disabled).toBe(true);
type('from', '');
expect(plot().disabled).toBe(false);
// A space is not text that names a star: the field looks empty, the scene offers nothing to
// choose for it, and the button going dead would have nothing on screen to explain it.
type('from', ' ');
expect(plot().disabled).toBe(false);
// The offer beside a refusal is the same request by another route, so it is held to the same
// test: moving the range with nothing to plot leaves the panel contradicting itself.
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: 1.8, gaveUp: false, least: true });
fixture.detectChanges();
const offer = () => host().querySelector<HTMLButtonElement>('[data-testid="route-summary"] button')!;
expect(offer().disabled).toBe(false);
type('from', 'Sol');
expect(offer().disabled).toBe(true);
});
it('does not replay the acquire wipe over the Routes panel, whose entries survive the trip', () => {
setReadout();
fixture.componentRef.setInput('routing', true);
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
fixture.componentRef.setInput('defaultTab', 'routes');
fixture.detectChanges();
// The wipe clips its panel for 380 ms, which swallows clicks on entries that are already there.
expect(host().querySelector('#dock-panel-routes')?.classList.contains('hud-acquire')).toBe(false);
tab('Display').click();
fixture.detectChanges();
expect(host().querySelector('#dock-panel-display')?.classList.contains('hud-acquire')).toBe(true);
});
it('says the search gave up rather than that there is no route, when that is what happened', () => {
fixture.componentRef.setInput('routing', true);
fixture.componentRef.setInput('defaultTab', 'routes');
const summary = () => host().querySelector('[data-testid="route-summary"]')?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: true, least: false });
fixture.detectChanges();
expect(summary()).toBe('Too many stars to search at this range.');
// Having looked everywhere the range reaches is a different answer, and one that can be stated.
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: false, least: true });
fixture.detectChanges();
expect(summary()).toContain('No chain of jumps up to');
// A range a chain was found at is worth offering — but the search that gave up at the range
// asked for still gave up, and saying "no route" beside the offer is saying it did not.
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: 6.4, gaveUp: true, least: false });
fixture.detectChanges();
expect(summary()).toBe('Too many stars to search at this range. 6.40 pc would reach.');
// The other way round: the range asked for was searched to exhaustion and the wider search was
// the one that gave up. There is no route at this range, and nothing further can be claimed.
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: false, least: false });
fixture.detectChanges();
expect(summary()).toBe('No route at this range.');
});
it('keeps what the Routes panel was set to across a trip to another tab', () => {
setReadout();
fixture.componentRef.setInput('routing', true);
fixture.componentRef.setInput('routeOptions', [{ id: 7, name: 'Sirius', subtitle: '2.6 pc' }]);
fixture.componentRef.setInput('defaultTab', 'routes');
fixture.detectChanges();
const destination = host().querySelector<HTMLInputElement>('#route-to')!;
destination.value = 'Sir';
destination.dispatchEvent(new Event('input'));
fixture.detectChanges();
host().querySelector<HTMLButtonElement>('#dock-panel-routes ul button')!.click();
const range = host().querySelector<HTMLInputElement>('#route-range')!;
range.value = '6';
range.dispatchEvent(new Event('input'));
fixture.detectChanges();
tab('Readout').click();
fixture.detectChanges();
expect(host().querySelector<HTMLElement>('#dock-panel-routes')!.hidden).toBe(true);
tab('Routes').click();
fixture.detectChanges();
expect(host().querySelector<HTMLElement>('#dock-panel-routes')!.hidden).toBe(false);
expect(host().querySelector<HTMLInputElement>('#route-to')!.value).toBe('Sirius');
expect(host().querySelector<HTMLInputElement>('#route-range')!.value).toBe('6');
});
describe('clock', () => {
let time: TimeStore;
beforeEach(() => {
time = TestBed.inject(TimeStore);
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
fixture.componentRef.setInput('defaultTab', 'display');
fixture.detectChanges();
});
afterEach(() => vi.unstubAllEnvs());
function button(name: string): HTMLButtonElement {
return [...host().querySelectorAll<HTMLButtonElement>('#dock-panel-display button')].find((b) => b.textContent?.trim() === name)!;
}
function radio(name: string): HTMLInputElement {
return [...host().querySelectorAll('#dock-panel-display label')].find((l) => l.textContent?.trim() === name)!.querySelector('input')!;
}
it('runs the same rates backwards, and keeps the direction when the rate changes', () => {
button('Backwards').click();
fixture.detectChanges();
expect(time.rate()).toBe(-1);
expect(button('Backwards').getAttribute('aria-pressed')).toBe('true');
// Still real time: the direction is not a fifth rate.
expect(radio('Real time').checked).toBe(true);
radio('1 d/s').click();
fixture.detectChanges();
expect(time.rate()).toBe(-86_400);
expect(radio('1 d/s').checked).toBe(true);
button('Backwards').click();
fixture.detectChanges();
expect(time.rate()).toBe(86_400);
expect(button('Backwards').getAttribute('aria-pressed')).toBe('false');
});
it('opens the date field on the clock’s date, named and held to the window the elements hold for', () => {
const field = host().querySelector<HTMLInputElement>('#clock-date')!;
expect(field.type).toBe('datetime-local');
expect(field.value).toBe(time.date().toISOString().slice(0, 16));
expect(host().querySelector('label[for="clock-date"]')?.textContent?.trim()).toBe('Date (UTC)');
expect(field.min).toBe('0001-01-01T00:00');
expect(field.max).toBe('3000-01-01T00:00');
expect(host().querySelector(`#${field.getAttribute('aria-describedby')}`)?.textContent).toContain('AD 1 to AD 3000');
});
it('jumps the clock to the date submitted, read as UTC', () => {
// Five and a half hours from UTC, so a field read as local time lands elsewhere: in UTC itself,
// where CI runs, the two readings are the same instant and this could not tell them apart.
vi.stubEnv('TZ', 'Asia/Kolkata');
const field = host().querySelector<HTMLInputElement>('#clock-date')!;
field.value = '2020-12-21T18:00';
button('Go').click();
fixture.detectChanges();
expect(time.date().toISOString().slice(0, 16)).toBe('2020-12-21T18:00');
expect(time.atNow()).toBe(false);
// Back to now puts the field back on the present too, not on the date left behind.
button('Back to now').click();
fixture.detectChanges();
expect(time.atNow()).toBe(true);
expect(host().querySelector<HTMLInputElement>('#clock-date')!.value).toBe(time.date().toISOString().slice(0, 16));
});
it('folds the sheet away on a phone once a date is set, so the system it covered can be seen', () => {
viewport.wide = false;
try {
host().querySelector<HTMLInputElement>('#clock-date')!.value = '2020-12-21T18:00';
button('Go').click();
fixture.detectChanges();
expect(host().querySelector('#dock-panel-display')).toBeNull();
} finally {
viewport.wide = true;
}
});
it('hands focus to the tab that folded, not to the page, so Enter opens the panel again', () => {
viewport.wide = false;
try {
const field = host().querySelector<HTMLInputElement>('#clock-date')!;
field.focus();
field.value = '2020-12-21T18:00';
button('Go').click();
fixture.detectChanges();
expect(document.activeElement?.id).toBe('dock-tab-display');
} finally {
viewport.wide = true;
}
});
it('keeps the date strip on screen on a phone: the tabs give way to it, and scroll', () => {
// At 360 px the system view's five tabs take 397 px, and pushed the strip past the right
// edge, where nothing scrolls: after Go on a phone the date was nowhere on screen.
fixture.componentRef.setInput('date', '2020-12-21');
fixture.componentRef.setInput('range', '417 AU');
fixture.detectChanges();
const tabs = host().querySelector('[role="tablist"]')!.classList;
expect(tabs.contains('min-w-0') && tabs.contains('overflow-x-auto')).toBe(true);
// Its own scrollbar, where the browser draws one, thin and dark: a desktop's default was a
// light bar 15 px tall across the dock.
expect(tabs.contains('scheme-dark') && tabs.contains('[scrollbar-width:thin]')).toBe(true);
expect(host().querySelector('[data-testid="hud-date"]')!.classList.contains('shrink-0')).toBe(true);
// The range keeps its width where it is shown, or '417 AU' wraps and the row grows 20 px; on a
// phone it is not shown, where at the present it took the Display tab out of sight.
const range = [...host().querySelectorAll('p')].find((p) => p.textContent?.includes('Range'))!.classList;
expect(range.contains('shrink-0') && range.contains('max-sm:hidden')).toBe(true);
});
it('brings the tab that matters back into view once the date strip has narrowed the tabs', () => {
// jsdom lays nothing out; what is checked is which tab is asked to be in view, and when.
const scrolled: string[] = [];
HTMLElement.prototype.scrollIntoView = function (this: HTMLElement) {
scrolled.push(this.id);
};
try {
// On a phone: the tab focus went back to, which after Go sat wholly out of sight.
viewport.wide = false;
host().querySelector<HTMLInputElement>('#clock-date')!.value = '2020-12-21T18:00';
button('Go').click();
fixture.detectChanges();
scrolled.length = 0;
fixture.componentRef.setInput('date', '2020-12-21');
fixture.detectChanges();
expect(scrolled).toEqual(['dock-tab-display']);
// On a wider window, where the panel stays open: its tab, focus being in the panel.
viewport.wide = true;
fixture.componentRef.setInput('date', '');
fixture.detectChanges();
tab('Display').click();
fixture.detectChanges();
host().querySelector<HTMLInputElement>('#clock-date')!.focus();
scrolled.length = 0;
fixture.componentRef.setInput('date', '2020-12-21');
fixture.detectChanges();
expect(scrolled).toEqual(['dock-tab-display']);
} finally {
viewport.wide = true;
delete (HTMLElement.prototype as Partial<HTMLElement>).scrollIntoView;
}
});
it('keeps the panel open on a wide screen, where it covers little of the scene', () => {
host().querySelector<HTMLInputElement>('#clock-date')!.value = '2020-12-21T18:00';
button('Go').click();
fixture.detectChanges();
expect(host().querySelector('#dock-panel-display')).not.toBeNull();
});
it('fills the date field again with the clock’s date when the panel is opened again', () => {
time.setDate(new Date('2020-12-21T18:00Z'));
fixture.componentInstance.toggleTab('display');
fixture.detectChanges();
fixture.componentInstance.toggleTab('display');
fixture.detectChanges();
expect(host().querySelector<HTMLInputElement>('#clock-date')!.value).toBe('2020-12-21T18:00');
});
});
});
+659
View File
@@ -0,0 +1,659 @@
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
HostListener,
inject,
input,
OnInit,
output,
signal,
viewChild,
} from '@angular/core';
import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store';
import { CLOCK_WINDOW, TIME_RATES, TimeStore } from '../../shared/state/time.store';
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
import { SearchComponent } from '../search/search.component';
import {
RouteRequest,
RouteResult,
RoutesPanelComponent,
RouteStarOption,
} from './routes-panel.component';
export interface HudReadout {
readonly label: string;
readonly value: string;
/**
* True when the figure was computed from other measurements rather than catalogued directly.
* Marked in the panel and explained in its footnote, so a reasoned number is never mistaken for
* an observed one.
*/
readonly derived?: boolean;
}
/** Which layers the scene draws. Every one is a real object in the scene, toggled by visibility. */
export interface HudDisplay {
readonly labels: boolean;
readonly orbits: boolean;
readonly grid: boolean;
readonly deepSky: boolean;
readonly sky: boolean;
/** The rings on stars known to host planets. */
readonly systems: boolean;
/** The graph of crossings within the range the Routes panel is set to. */
readonly jumpLinks: boolean;
/** Drawn flat: an orthographic projection, looking down on the plane. */
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,
};
const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
{ key: 'labels', label: 'Labels' },
{ key: 'orbits', label: 'Orbits' },
{ key: 'grid', label: 'Grid' },
{ key: 'deepSky', label: 'Deep sky' },
{ key: 'sky', label: 'Sky' },
{ key: 'systems', label: 'Systems' },
{ key: 'jumpLinks', label: 'Jump links' },
{ key: 'plan', label: 'Plan view' },
];
export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | '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. */
const WIDE_VIEWPORT = '(min-width: 640px)';
/** 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;
function isWideViewport(): boolean {
return wideViewportQuery?.matches ?? true;
}
/**
* The instrument's dock: one rail across the bottom of the viewport carrying every tool and
* readout, so the top of the screen keeps only the scale ladder and the nameplate.
*
* The tab strip is pinned to the bottom edge and never moves; whichever panel is open grows
* upward from it. That is why the search field sits at the *bottom* of its panel with the
* results above — the thing being typed into stays put while the list grows.
*
* `null` for the active tab is a real state, not an error: the strip alone. It is the default
* below `sm`, where the panel would cover most of the scene, and wherever there is no readout
* to show by default. Purely presentational: readouts and layer state arrive as inputs, the
* only things it emits are layer toggles.
*/
@Component({
selector: 'app-hud-dock',
changeDetection: ChangeDetectionStrategy.OnPush,
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',
},
template: `
<!-- 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
click in the empty band beside the panel — where the scene, and its labels, are. -->
<div class="flex flex-col items-start">
@if (activeTab(); as tab) {
<!-- Switching tabs remounts the panel and replays its acquire wipe: a new readout
locking on, once per switch, never per keystroke. -->
@switch (tab) {
@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"
>
<app-search (picked)="onPicked()" />
</section>
}
@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"
>
<p class="type-label text-muted">{{ eyebrow() }}</p>
<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>
<!-- 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
system nobody could keep. -->
@if (keepableStarId() !== null) {
<button
type="button"
[attr.aria-label]="
(bookmarks.has('star', keepableStarId()!) ? 'Forget ' : 'Keep ') + title()
"
[attr.aria-pressed]="bookmarks.has('star', keepableStarId()!)"
(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"
[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>
@if (subtitle()) {
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
}
@if (readouts().length) {
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
@for (readout of readouts(); track readout.label) {
<div>
<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>
</div>
}
</dl>
}
@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>
}
</section>
}
@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"
>
@if (bookmarks.bookmarks().length) {
<ul class="max-h-64 divide-y divide-border/25 overflow-y-auto">
@for (
bookmark of bookmarks.bookmarks();
track bookmark.kind + ':' + bookmark.id
) {
<li class="flex items-stretch">
<button
type="button"
(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"
>
<span class="min-w-0 flex-1 truncate text-sm text-text">{{
bookmark.name
}}</span>
<span class="type-label shrink-0 text-muted">{{
bookmark.kind === 'star' ? 'System' : 'Body'
}}</span>
</button>
<button
type="button"
[attr.aria-label]="'Forget ' + bookmark.name"
(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"
>
<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" />
</svg>
</button>
</li>
}
</ul>
} @else {
<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.
</p>
}
</section>
}
@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"
>
@if (display()) {
<p class="type-label text-muted">Layers</p>
<div class="mt-2 flex flex-wrap gap-2">
@for (layer of layers; track layer.key) {
<button
type="button"
[attr.aria-pressed]="isOn(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]="
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. -->
<span
aria-hidden="true"
class="h-1.5 w-1.5 border border-current"
[class.bg-current]="isOn(layer.key)"
></span>
{{ layer.label }}
</button>
}
</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 text-muted" [class.mt-4]="display()">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"
>
<!-- A rate is picked by its size and the toggle after the radios says which way it
runs, so a month a second backwards is the same radio as forwards. -->
@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]="
Math.abs(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]="Math.abs(time.rate()) === rate.secondsPerSecond"
(change)="time.setRate(Math.sign(time.rate()) * rate.secondsPerSecond)"
/>
{{ rate.label }}
</label>
}
<button
type="button"
[attr.aria-pressed]="time.rate() < 0"
(click)="time.setRate(-time.rate())"
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]="
time.rate() < 0
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
: 'border-border/60 text-muted hover:border-border hover:text-text'
"
>
<span
aria-hidden="true"
class="h-1.5 w-1.5 border border-current"
[class.bg-current]="time.rate() < 0"
></span>
Backwards
</button>
@if (!time.atNow()) {
<button
type="button"
(click)="backToNow()"
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>
<!-- A form, so Enter in the field goes there and the browser holds the field to its
min and max before anything is submitted. Submitted rather than applied on each
change: Chrome reports a year typed digit by digit as 0002, 0020, 0202 and 2020,
and the sky would jump through every one. -->
<form class="mt-2 flex flex-wrap items-center gap-2" (submit)="goToDate($event)">
<label for="clock-date" class="type-label text-muted">Date (UTC)</label>
<input
id="clock-date"
name="date"
type="datetime-local"
required
[min]="clockWindow.min"
[max]="clockWindow.max"
[value]="dateField()"
aria-describedby="clock-date-window"
class="hud-surface min-w-0 flex-1 px-2.5 py-1 text-sm text-text tabular-nums caret-accent scheme-dark focus:border-accent focus:outline-none sm:flex-none"
/>
<button
type="submit"
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"
>
Go
</button>
<!-- One line: on a phone the panel is a sheet over the system it sets the date of, and
each card already says how far its own orbit strays. -->
<p id="clock-date-window" class="w-full text-[10px] text-muted">
AD 1 to AD 3000, where the planets’ elements hold.
</p>
</form>
</section>
}
}
}
<!-- Hidden rather than unmounted: the departure, destination and range it holds would
otherwise reset on every trip to another tab, while the scene kept drawing the graph at
the old range. No acquire wipe, unlike the panels around it: this is the one that comes
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. -->
@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"
>
<app-routes-panel
[result]="routeResult()"
[pending]="routePending()"
[options]="routeOptions()"
[currentStar]="currentStar()"
(queryChange)="routeQuery.emit($event)"
(routeRequested)="routeRequested.emit($event)"
(starSelected)="onRouteStarSelected($event)"
(rangeChange)="jumpRangeChange.emit($event)"
/>
</section>
}
<div class="hud-brackets hud-surface pointer-events-auto flex w-full items-stretch">
<!-- The tabs give way to the date and the range, and scroll: the five of the system view
take 397 px, and on a portrait phone they pushed the date off the right edge. Where
the browser draws a scrollbar of its own, it is a thin dark one: on a desktop window
under 770 px wide its default was a light bar 15 px tall across the dark dock. -->
<div
role="tablist"
aria-label="Dock"
class="flex min-w-0 scheme-dark items-stretch divide-x divide-border/40 overflow-x-auto [scrollbar-width:thin]"
>
@for (tab of tabs(); track tab) {
<button
type="button"
role="tab"
[id]="'dock-tab-' + tab"
[attr.aria-selected]="activeTab() === tab"
[attr.aria-controls]="activeTab() === tab ? 'dock-panel-' + tab : null"
(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]="
activeTab() === tab
? 'bg-accent/15 text-accent'
: 'text-muted hover:bg-accent/8 hover:text-accent'
"
>
{{ tabLabel(tab) }}
</button>
}
</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 shrink-0 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>
}
<!-- Not on a phone, where it never showed before the tabs gave way to it: kept, its 121 px
took the Display tab out of sight at the present, the state the map opens in. -->
@if (range()) {
<p
class="flex shrink-0 items-baseline gap-2 border-l border-border/40 px-3 py-2 max-sm:hidden sm:px-4"
[class.ml-auto]="!date()"
>
<span class="type-label text-muted">Range</span>
<span class="text-sm text-accent tabular-nums">{{ range() }}</span>
</p>
}
</div>
</div>
`,
})
export class HudDockComponent implements OnInit {
/** Readout panel contents. An empty title means there is nothing to read out, and no tab for it. */
readonly eyebrow = input('');
readonly title = input('');
readonly subtitle = input('');
readonly readouts = input<readonly HudReadout[]>([]);
/** Standing caveat for the current view, e.g. that galactic structure is a model. */
readonly note = input('');
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
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 unless it
* has the clock.
*/
readonly display = input<HudDisplay | null>(null);
/** The clock without the layers, for a surface that is drawn at its date: the tab is then "Clock". */
readonly clock = input(false);
/** Which panel is open on a wide viewport when the dock mounts. */
readonly defaultTab = input<DockTab | null>(null);
/** Routing: what the scene found, what it offers for the fields, and where the view is. */
readonly routeResult = input<RouteResult | null>(null);
readonly routePending = input(false);
readonly routeOptions = input<readonly RouteStarOption[]>([]);
readonly currentStar = input<RouteStarOption | null>(null);
/** The star the readout is about, where there is one to keep — a scale is not a place. */
readonly keepableStarId = input<number | null>(null);
/** Present makes the Routes tab available; absent means this surface cannot route. */
readonly routing = input(false);
readonly displayChange = output<HudDisplay>();
readonly routeQuery = output<string>();
readonly routeRequested = output<RouteRequest>();
readonly routeStarSelected = output<number>();
readonly jumpRangeChange = output<number>();
readonly bookmarkChosen = output<Bookmark>();
readonly layers = DISPLAY_LAYERS;
readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived));
readonly tabs = computed<readonly DockTab[]>(() => [
'search',
...(this.title() ? (['readout'] as const) : []),
...(this.routing() ? (['routes'] as const) : []),
// 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.
'bookmarks',
...(this.display() || this.clock() ? (['display'] as const) : []),
]);
readonly activeTab = signal<DockTab | null>(null);
readonly bookmarks = inject(BookmarksStore);
readonly time = inject(TimeStore);
readonly timeRates = TIME_RATES;
readonly clockWindow = CLOCK_WINDOW;
protected readonly Math = Math;
/**
* What the date field holds when the panel opens: the clock's date at that moment. Not bound to
* the running clock, which would rewrite the field under the reader's typing on every render.
*/
readonly dateField = signal('');
private readonly search = viewChild(SearchComponent);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly dateShown = computed(() => this.date() !== '');
/**
* The tab list gives way to the date strip but keeps its scroll, so once the strip is drawn the
* tab that matters can be wholly out of sight: after Go on a phone, the tab focus went back to
* (0 of its 79 px at 360, 390 and 412 wide), and on a desktop window 640 to 770 px wide, the tab
* of the panel left open. Brought back into view whenever the strip comes or goes: the focused
* tab, else the selected one.
*/
private readonly keepTabInView = afterRenderEffect(() => {
this.dateShown();
const list = this.host.nativeElement.querySelector('[role="tablist"]');
const focused = document.activeElement;
const tab = focused && list?.contains(focused) ? focused : list?.querySelector('[aria-selected="true"]');
// Optional: jsdom, which the unit tests run in, lays nothing out and has no scrollIntoView.
tab?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
});
ngOnInit(): void {
this.activeTab.set(isWideViewport() ? this.defaultTab() : null);
this.fillDateField();
}
tabLabel(tab: DockTab): string {
return tab === 'display' && !this.display() ? 'Clock' : TAB_LABELS[tab];
}
isOn(key: keyof HudDisplay): boolean {
return this.display()?.[key] ?? false;
}
toggleTab(tab: DockTab): void {
this.activeTab.set(this.activeTab() === tab ? null : tab);
this.fillDateField();
}
/** The field's value is read as UTC, which is what the strip and the note print dates in. */
goToDate(event: SubmitEvent): void {
event.preventDefault();
const field = (event.target as HTMLFormElement).elements.namedItem('date') as HTMLInputElement;
if (this.time.setDate(new Date(`${field.value}Z`))) {
// The field now says what the signal behind it does, so a later reset that fills it with
// the present is a change the binding writes back, not one it drops as the same value.
this.dateField.set(field.value);
// On a phone the sheet covers the system it has just set the date of: at 360 by 640 every
// orbit lies behind it. The thing to look at is now the scene, as after a search.
// Focus goes back to the tab that folded, not to the page: the form it was in is gone.
if (!isWideViewport()) {
const tab = this.activeTab();
this.activeTab.set(null);
this.host.nativeElement.querySelector<HTMLElement>(`#dock-tab-${tab}`)?.focus();
}
}
}
backToNow(): void {
this.time.reset();
this.fillDateField();
}
private fillDateField(): void {
this.dateField.set(this.time.date().toISOString().slice(0, 16));
}
toggleLayer(key: keyof HudDisplay): void {
const current = this.display();
if (current) {
this.displayChange.emit({ ...current, [key]: !current[key] });
}
}
/** A step on a plotted route was chosen: fly there, and leave the route up to walk it from. */
onRouteStarSelected(starId: number): void {
this.routeStarSelected.emit(starId);
}
/** Choosing a kept place is the same move as choosing a search result: the panel has done
* its job and the thing to look at is now the scene. */
onBookmarkChosen(bookmark: Bookmark): void {
this.bookmarkChosen.emit(bookmark);
this.onPicked();
}
onPicked(): void {
// A result was chosen: the thing to look at is now the scene, so hand the panel back to the
// readout where there is one, and fold the sheet away where there is not. On a narrow
// viewport it always folds away — there the panel is a sheet over most of the scene, and
// reopening it onto whatever was just flown to is the opposite of what was asked for.
this.activeTab.set(isWideViewport() && this.title() ? 'readout' : null);
}
/** `/` opens the search from anywhere, unless something else is already taking text. */
@HostListener('document:keydown', ['$event'])
onKeydown(event: KeyboardEvent): void {
if (event.key !== '/' || event.ctrlKey || event.metaKey || event.altKey) {
return;
}
const target = event.target as HTMLElement | null;
if (
target &&
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
) {
return;
}
event.preventDefault();
this.activeTab.set('search');
// The field only exists after the panel renders; defer the focus to after that pass.
setTimeout(() => this.search()?.focus());
}
/** On a narrow viewport the panel is a sheet over the scene: tapping the scene folds it away. */
@HostListener('document:pointerdown', ['$event'])
onDocumentPointerDown(event: PointerEvent): void {
if (
this.activeTab() &&
!isWideViewport() &&
!this.host.nativeElement.contains(event.target as Node)
) {
this.activeTab.set(null);
}
}
}
@@ -0,0 +1,249 @@
import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
import { formatParsecs } from '../../shared/format/quantity';
/**
* The widest jump the Routes panel offers. Past it the drawn graph is a solid sheet of lines, and
* a route search through the dense core around the Sun walks thousands of stars at every step.
*/
export const MAX_JUMP_RANGE_PC = 8;
/** A star offered for one of the two fields, as the panel needs to show it. */
export interface RouteStarOption {
readonly id: number;
readonly name: string;
readonly subtitle: string;
}
/** What the scene worked out, once it has been asked. */
export interface RouteResult {
/** The chain, departure first. Empty when there is no route at the range asked for. */
readonly stars: readonly { id: number; name: string }[];
readonly totalPc: number;
/** A range that would open a route, where none was found at the one asked for. */
readonly neededRangePc: number | null;
/** True when the search gave up rather than showing there is no route at this range. */
readonly gaveUp: boolean;
/** True when the search for a range that would work looked everywhere up to the widest offered. */
readonly least: boolean;
}
export interface RouteRequest {
readonly fromId: number;
readonly toId: number;
readonly rangePc: number;
}
/** Which end of the journey a query is for. */
type Field = 'from' | 'to';
/**
* Departure, destination, range, and the chain between them.
*
* The one genuinely two-sided tool in the instrument, and the reason the range lives here
* rather than beside the layer toggle that draws the graph: the number that decides which
* crossings are possible is the same number in both places, and a control is easier to trust
* where its consequence is printed.
*
* Presentational. It knows how to ask; the scene knows the catalogue and does the walking.
*/
@Component({
selector: 'app-routes-panel',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex flex-col gap-3">
<div class="grid gap-2 sm:grid-cols-2">
@for (field of fields; track field) {
<div class="relative">
<label [for]="'route-' + field" class="type-label text-muted">{{ field === 'from' ? 'Departure' : 'Destination' }}</label>
<input
[id]="'route-' + field"
type="text"
autocomplete="off"
[value]="text(field)"
[placeholder]="field === 'from' ? 'From this system' : 'Search a star'"
(input)="onInput(field, $event)"
(keydown.escape)="closeOptions()"
class="hud-surface mt-1 w-full px-2.5 py-1.5 text-sm text-text caret-accent placeholder:text-muted focus:border-accent focus:outline-none"
/>
@if (open() === field && options().length) {
<ul class="hud-surface absolute bottom-full left-0 z-10 mb-1 max-h-48 w-full overflow-y-auto divide-y divide-border/25">
@for (option of options(); track option.id) {
<li>
<button
type="button"
(click)="choose(field, option)"
class="flex w-full items-baseline gap-3 px-2.5 py-1.5 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">{{ option.name }}</span>
<span class="type-label shrink-0 truncate text-muted">{{ option.subtitle }}</span>
</button>
</li>
}
</ul>
}
</div>
}
</div>
<div class="flex items-center gap-3">
<label for="route-range" class="type-label shrink-0 text-muted">Jump range</label>
<input
id="route-range"
type="range"
[min]="minRangePc"
[max]="maxRangePc"
step="0.1"
[value]="rangePc()"
(input)="onRange($event)"
class="h-1 min-w-0 flex-1 appearance-none rounded-none bg-border accent-accent"
/>
<output for="route-range" class="w-20 shrink-0 text-right text-sm text-accent tabular-nums">{{ rangeLabel() }}</output>
</div>
<div class="flex items-center gap-3">
<button
type="button"
[disabled]="!canPlot() || pending()"
(click)="plot()"
class="type-label border border-border/60 px-3 py-1.5 text-muted transition-colors enabled:hover:border-accent/70 enabled:hover:text-accent disabled:opacity-40 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
>
{{ pending() ? 'Plotting…' : 'Plot route' }}
</button>
@if (result(); as plotted) {
@if (plotted.stars.length) {
<p data-testid="route-summary" class="text-sm text-text tabular-nums">
{{ plotted.stars.length - 1 }} {{ plotted.stars.length === 2 ? 'jump' : 'jumps' }} <span class="text-muted">·</span> {{ format(plotted.totalPc) }}
</p>
} @else {
<p data-testid="route-summary" class="text-sm text-muted">
@if (plotted.gaveUp) {
Too many stars to search at this range.
} @else {
No route at this range.
}
@if (plotted.neededRangePc !== null) {
<button
type="button"
[disabled]="!canPlot() || pending()"
(click)="raiseTo(plotted.neededRangePc)"
class="text-accent underline decoration-accent/40 underline-offset-2 disabled:opacity-40 disabled:no-underline enabled:hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent"
>
{{ format(plotted.neededRangePc) }} would reach.
</button>
} @else if (plotted.least) {
No chain of jumps up to {{ format(maxRangePc) }} reaches it.
}
</p>
}
}
</div>
@if (result()?.stars?.length) {
<ol data-testid="route-steps" class="hud-surface divide-y divide-border/25">
@for (step of result()!.stars; track step.id; let i = $index) {
<li>
<button
type="button"
(click)="starSelected.emit(step.id)"
class="flex w-full items-baseline gap-3 px-2.5 py-1.5 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="type-label w-6 shrink-0 text-muted tabular-nums">{{ i + 1 }}</span>
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ step.name }}</span>
</button>
</li>
}
</ol>
}
</div>
`
})
export class RoutesPanelComponent {
/** Whatever the scene found for the last query it was given. */
readonly result = input<RouteResult | null>(null);
/** Matches for the field currently being typed into, ranked by the scene. */
readonly options = input<readonly RouteStarOption[]>([]);
/** The scene is still working the last request out; asking again would only queue behind it. */
readonly pending = input(false);
/** The star the view is currently inside, offered as the departure without typing. */
readonly currentStar = input<RouteStarOption | null>(null);
readonly queryChange = output<string>();
readonly routeRequested = output<RouteRequest>();
readonly starSelected = output<number>();
/** The graph is drawn at whatever range this panel is set to, so the scene follows it. */
readonly rangeChange = output<number>();
readonly fields: readonly Field[] = ['from', 'to'];
/** A tenth of a parsec is finer than the catalogue's own distances are known to. */
readonly minRangePc = 0.5;
readonly maxRangePc = MAX_JUMP_RANGE_PC;
readonly rangePc = signal(3);
readonly open = signal<Field | null>(null);
private readonly chosen = signal<Record<Field, RouteStarOption | null>>({ from: null, to: null });
private readonly typed = signal<Record<Field, string>>({ from: '', to: '' });
/**
* Departure falls back to wherever the view already is, so one field is usually enough — but only
* while the field is empty. Text left in it that names no chosen star used to fall back all the
* same, so the panel read "Sol" and the route left from whatever the view had since flown to.
*/
// Trimmed, as the scene trims the same text before offering matches for it: a field holding one
// space looks empty, offers nothing to choose, and would otherwise count as a departure.
private readonly departure = computed(() => this.chosen().from ?? (this.typed().from.trim() ? null : this.currentStar()));
readonly canPlot = computed(() => this.departure() !== null && this.chosen().to !== null);
readonly rangeLabel = computed(() => formatParsecs(this.rangePc()));
text(field: Field): string {
return this.chosen()[field]?.name ?? this.typed()[field];
}
format(distancePc: number): string {
return formatParsecs(distancePc);
}
onInput(field: Field, event: Event): void {
const value = (event.target as HTMLInputElement).value;
this.typed.update((current) => ({ ...current, [field]: value }));
// Typing over a chosen star un-chooses it: the field says what it will be searched for.
this.chosen.update((current) => ({ ...current, [field]: null }));
this.open.set(field);
this.queryChange.emit(value);
}
choose(field: Field, option: RouteStarOption): void {
this.chosen.update((current) => ({ ...current, [field]: option }));
this.closeOptions();
}
closeOptions(): void {
this.open.set(null);
this.queryChange.emit('');
}
onRange(event: Event): void {
const value = Number((event.target as HTMLInputElement).value);
this.rangePc.set(value);
this.rangeChange.emit(value);
}
raiseTo(rangePc: number): void {
// Rounded up to the control's own step, so the number shown is one it can actually hold —
// and up rather than down, since down would land just short of the crossing it names.
const stepped = Math.min(this.maxRangePc, Math.ceil(rangePc * 10) / 10);
this.rangePc.set(stepped);
this.rangeChange.emit(stepped);
this.plot();
}
plot(): void {
const from = this.departure();
const to = this.chosen().to;
if (from && to) {
this.routeRequested.emit({ fromId: from.id, toId: to.id, rangePc: this.rangePc() });
}
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ const MATCH_SUBSTRING = 1;
const NO_MATCH = 0; const NO_MATCH = 0;
/** /**
* Order for results that match equally well. Solar-system bodies are eighteen famous objects * Order for results that match equally well. Solar-system bodies are a few dozen named worlds
* and win ties outright; a star outranks an exoplanet because searching a name like "Proxima" * and win ties outright; a star outranks an exoplanet because searching a name like "Proxima"
* is usually an attempt to reach the system rather than one particular planet in it. * is usually an attempt to reach the system rather than one particular planet in it.
*/ */
@@ -34,7 +34,9 @@ const IO: BodyRecord = {
argumentOfPeriapsisDeg: 0, argumentOfPeriapsisDeg: 0,
meanAnomalyAtEpochDeg: 0, meanAnomalyAtEpochDeg: 0,
epochJd: 2451545.0 epochJd: 2451545.0
} },
rates: { meanMotionDegPerDay: 203.4889583, longitudeOfAscendingNodeDegPerDay: 0, argumentOfPeriapsisDegPerDay: 0 },
orbitSource: 'test'
}; };
const PROXIMA_B: ExoplanetRecord = { const PROXIMA_B: ExoplanetRecord = {
+19 -6
View File
@@ -1,4 +1,4 @@
import { Component, computed, signal } from '@angular/core'; import { Component, computed, ElementRef, output, signal, viewChild } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { DataLoaderService } from '../../core/data/data-loader.service'; import { DataLoaderService } from '../../core/data/data-loader.service';
@@ -25,13 +25,17 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
selector: 'app-search', selector: 'app-search',
imports: [ReticleIconComponent], imports: [ReticleIconComponent],
template: ` template: `
<div class="fixed top-4 left-1/2 z-20 w-[26rem] max-w-[calc(100%-2rem)] -translate-x-1/2 font-body"> <!-- Sits on the dock's tab strip: the field is pinned to the bottom of the block and the
results grow upward above it, so typing never moves the thing being typed into. DOM
order stays field-then-results (flex-col-reverse), which is also the focus order. -->
<div class="flex flex-col-reverse font-body">
<!-- The one genuinely translucent surface, so it alone carries a (small) backdrop blur. <!-- The one genuinely translucent surface, so it alone carries a (small) backdrop blur.
The ring is the keyboard-focus indicator: a border hue shift alone is invisible over The ring is the keyboard-focus indicator: a border hue shift alone is invisible over
the star field. --> the star field. -->
<div class="hud-brackets hud-surface relative backdrop-blur-sm transition-colors focus-within:border-accent focus-within:ring-1 focus-within:ring-accent/50"> <div class="hud-brackets hud-surface relative backdrop-blur-sm transition-colors focus-within:border-accent focus-within:ring-1 focus-within:ring-accent/50">
<app-reticle-icon class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-accent/70" /> <app-reticle-icon class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-accent/70" />
<input <input
#field
type="text" type="text"
placeholder="Search stars, planets, exoplanets…" placeholder="Search stars, planets, exoplanets…"
[value]="query()" [value]="query()"
@@ -42,11 +46,10 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
</div> </div>
<!-- One panel for both outcomes, so crossing the match boundary while typing swaps only <!-- One panel for both outcomes, so crossing the match boundary while typing swaps only
the rows instead of remounting the panel and replaying its acquire wipe per the rows instead of remounting the panel. Gated on the index being ready: "no matches"
keystroke. Gated on the index being ready: "no matches" may only ever describe a may only ever describe a search that actually ran against the loaded catalogues. -->
search that actually ran against the loaded catalogues. -->
@if (indexReady() && hasQuery()) { @if (indexReady() && hasQuery()) {
<div class="hud-acquire hud-surface mt-2"> <div class="hud-surface mb-2">
@if (results().length) { @if (results().length) {
<p class="type-label border-b border-border/40 px-3 py-1.5 text-muted"> <p class="type-label border-b border-border/40 px-3 py-1.5 text-muted">
Matches <span class="text-accent tabular-nums">{{ matchTotal() }}</span> Matches <span class="text-accent tabular-nums">{{ matchTotal() }}</span>
@@ -78,6 +81,11 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
}) })
export class SearchComponent { export class SearchComponent {
readonly query = signal(''); readonly query = signal('');
/** Fires once a result has been chosen and navigation kicked off — the dock uses it to hand
* the view back to the readout, since the thing to look at is now the scene, not the box. */
readonly picked = output<void>();
private readonly field = viewChild.required<ElementRef<HTMLInputElement>>('field');
/** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */ /** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */
private readonly index = signal<IndexedSearchEntry[]>([]); private readonly index = signal<IndexedSearchEntry[]>([]);
/** False until the catalogues have loaded — and forever if they fail, which beats a false /** False until the catalogues have loaded — and forever if they fail, which beats a false
@@ -117,6 +125,10 @@ export class SearchComponent {
this.query.set(''); this.query.set('');
} }
focus(): void {
this.field().nativeElement.focus();
}
kindLabel(kind: SearchResultKind): string { kindLabel(kind: SearchResultKind): string {
return KIND_LABELS[kind]; return KIND_LABELS[kind];
} }
@@ -129,6 +141,7 @@ export class SearchComponent {
} else if (result.bodyId) { } else if (result.bodyId) {
void this.router.navigate(['/body', result.bodyId]); void this.router.navigate(['/body', result.bodyId]);
} }
this.picked.emit();
} }
private async buildIndex(): Promise<void> { private async buildIndex(): Promise<void> {
+4 -3
View File
@@ -5,12 +5,13 @@ import { ExoplanetRecord } from '../models/exoplanet.model';
import { appearanceForBody, appearanceForExoplanet, heliocentricDistanceAu } from './body-appearance'; import { appearanceForBody, appearanceForExoplanet, heliocentricDistanceAu } from './body-appearance';
import { DEFAULT_EPOCH_JD } from './constants'; import { DEFAULT_EPOCH_JD } from './constants';
const RATES = { meanMotionDegPerDay: 1, longitudeOfAscendingNodeDegPerDay: 0, argumentOfPeriapsisDegPerDay: 0 };
const ORBIT = { eccentricity: 0, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD }; const ORBIT = { eccentricity: 0, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD };
const JUPITER: BodyRecord = { id: 'jupiter', systemStarId: 0, name: 'Jupiter', kind: 'planet', radiusKm: 69911, orbit: { ...ORBIT, semiMajorAxisAu: 5.204 } }; const JUPITER: BodyRecord = { id: 'jupiter', systemStarId: 0, name: 'Jupiter', kind: 'planet', radiusKm: 69911, orbit: { ...ORBIT, semiMajorAxisAu: 5.204 }, rates: RATES, orbitSource: 'test' };
/** Europa's own orbit is around Jupiter: 671,000 km, which is 0.00449 AU. */ /** Europa's own orbit is around Jupiter: 671,000 km, which is 0.00449 AU. */
const EUROPA: BodyRecord = { id: 'europa', systemStarId: 0, name: 'Europa', kind: 'moon', radiusKm: 1560, parentBodyId: 'jupiter', orbit: { ...ORBIT, semiMajorAxisAu: 0.00449 } }; const EUROPA: BodyRecord = { id: 'europa', systemStarId: 0, name: 'Europa', kind: 'moon', radiusKm: 1560, parentBodyId: 'jupiter', orbit: { ...ORBIT, semiMajorAxisAu: 0.00449 }, rates: RATES, orbitSource: 'test' };
const EARTH: BodyRecord = { id: 'earth', systemStarId: 0, name: 'Earth', kind: 'planet', radiusKm: 6371, orbit: { ...ORBIT, semiMajorAxisAu: 1 } }; const EARTH: BodyRecord = { id: 'earth', systemStarId: 0, name: 'Earth', kind: 'planet', radiusKm: 6371, orbit: { ...ORBIT, semiMajorAxisAu: 1 }, rates: RATES, orbitSource: 'test' };
const ORPHAN: BodyRecord = { ...EUROPA, id: 'orphan', parentBodyId: 'nowhere' }; const ORPHAN: BodyRecord = { ...EUROPA, id: 'orphan', parentBodyId: 'nowhere' };
const BODIES = [JUPITER, EUROPA, EARTH, ORPHAN]; const BODIES = [JUPITER, EUROPA, EARTH, ORPHAN];
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { brightestWithin, brightnessIndex, brightnessOrder } from './brightest';
interface TestStar {
id: number;
x: number;
y: number;
z: number;
magnitude: number;
}
/** A pseudo-random cloud with repeated magnitudes, so ties are exercised. */
function cloud(count: number): TestStar[] {
let seed = 5;
const random = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
return Array.from({ length: count }, (_, id) => ({
id: id * 7,
x: random() * 200 - 100,
y: random() * 200 - 100,
z: random() * 200 - 100,
magnitude: Math.round(random() * 40) / 4
}));
}
describe('brightnessOrder', () => {
it('puts the brightest first and keeps catalogue order among equals', () => {
const stars = [{ magnitude: 5 }, { magnitude: -1 }, { magnitude: 5 }, { magnitude: 2 }];
expect(Array.from(brightnessOrder(stars))).toEqual([1, 3, 0, 2]);
});
it('orders nothing for an empty catalogue', () => {
expect(brightnessOrder([])).toHaveLength(0);
});
});
describe('brightestWithin', () => {
// What the labels used to do on every pass: filter the whole catalogue, then sort what was left.
function filterThenSort(stars: TestStar[], centre: { x: number; y: number; z: number }, radius: number, alwaysId: number | null): number[] {
return stars
.filter((star) => Math.hypot(star.x - centre.x, star.y - centre.y, star.z - centre.z) <= radius || star.id === alwaysId)
.sort((a, b) => a.magnitude - b.magnitude)
.map((star) => star.id);
}
it('yields exactly what filtering and then sorting the catalogue did, in the same order', () => {
const stars = cloud(3000);
const index = brightnessIndex(stars);
const centre = { x: 12, y: -30, z: 5 };
for (const [radius, alwaysId] of [[40, null], [15, 7 * 2999], [0, 7 * 11], [500, null]] as const) {
const lazy = Array.from(brightestWithin(stars, index, centre, radius, alwaysId), (star) => star.id);
expect(lazy).toEqual(filterThenSort(stars, centre, radius, alwaysId));
}
});
it('includes a star lying exactly on the radius, as the scan it replaced did', () => {
const stars = [
{ id: 1, x: 3, y: 4, z: 0, magnitude: 1 },
{ id: 2, x: 3, y: 4.001, z: 0, magnitude: 0 }
];
expect(Array.from(brightestWithin(stars, brightnessIndex(stars), { x: 0, y: 0, z: 0 }, 5, null), (star) => star.id)).toEqual([1]);
});
it('reads no further than the caller takes', () => {
const stars = cloud(3000);
let read = 0;
const counted = new Proxy(stars, {
get(target, key, receiver) {
if (typeof key === 'string' && /^\d+$/.test(key)) {
read++;
}
return Reflect.get(target, key, receiver);
}
});
const taken: number[] = [];
for (const star of brightestWithin(counted, brightnessIndex(stars), { x: 0, y: 0, z: 0 }, 1000, null)) {
taken.push(star.id);
if (taken.length === 15) {
break;
}
}
expect(taken).toHaveLength(15);
expect(read).toBe(15);
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* The catalogue in order of brightness, worked out once and walked as often as needed.
*
* Two parts of the map want "the brightest stars in this region": the labels, which name about
* fifteen of them five times a second, and the star field, which draws a budget of them. Sorting
* the region each time is paid for every star in it. At the opening view the label region holds
* some 60 000 stars, and sorting them to name fifteen took 55-70 ms a pass, a stall five times
* a second on any machine. Walking one shared order and stopping when enough have been taken
* costs only the stars looked at before that.
*/
export interface BrightnessRanked {
readonly magnitude: number;
}
export interface Positioned {
readonly x: number;
readonly y: number;
readonly z: number;
}
/**
* Indices into `stars`, brightest (lowest magnitude) first. Ties keep catalogue order: typed-array
* sort is required to be stable, exactly as the sort of the stars themselves was.
*/
export function brightnessOrder(stars: readonly BrightnessRanked[]): Uint32Array {
// Compared from a typed copy rather than off the stars: the sort reads two magnitudes per
// comparison, some eight million times for the whole catalogue: 83 ms this way, 104-139 ms reading them off the stars.
const magnitudes = Float64Array.from(stars, (star) => star.magnitude);
return Uint32Array.from(stars.keys()).sort((a, b) => magnitudes[a] - magnitudes[b]);
}
/**
* The brightness order, with each star's position and id laid out beside it in that order.
*
* A walk has to test every star it passes, and near the Sun it passes nearly all of them: a 4 pc
* label radius holds a few dozen stars, faint dwarfs deep in the order, so the walk rarely finds
* fifteen to name before the end. Reading the stars themselves in brightness order jumps all over
* the catalogue, and a full walk took 19-23 ms — slower than the scan and sort it replaced. Read
* from these arrays, laid out in the order they are walked, the same walk touches memory in
* sequence and reads a star only when it yields one.
*/
export interface BrightnessIndex {
/** Indices into the catalogue, brightest first. */
readonly order: Uint32Array;
/** Positions in the same order, three to a star, at full precision so a star on a radius stays on it. */
readonly positions: Float64Array;
readonly ids: Float64Array;
}
export function brightnessIndex<T extends BrightnessRanked & Positioned & { readonly id: number }>(stars: readonly T[]): BrightnessIndex {
const order = brightnessOrder(stars);
const positions = new Float64Array(order.length * 3);
const ids = new Float64Array(order.length);
order.forEach((index, at) => {
const star = stars[index];
positions[at * 3] = star.x;
positions[at * 3 + 1] = star.y;
positions[at * 3 + 2] = star.z;
ids[at] = star.id;
});
return { order, positions, ids };
}
/**
* The stars within `radiusPc` of `centre`, brightest first, plus the one star `alwaysId` names
* wherever it is — handed over lazily, so a caller that stops after the first few pays for no
* more than it read.
*/
export function* brightestWithin<T extends BrightnessRanked & Positioned & { readonly id: number }>(
stars: readonly T[],
index: BrightnessIndex,
centre: Positioned,
radiusPc: number,
alwaysId: number | null
): Generator<T> {
const { order, positions, ids } = index;
const radiusSq = radiusPc * radiusPc;
for (let at = 0; at < order.length; at++) {
const dx = positions[at * 3] - centre.x;
const dy = positions[at * 3 + 1] - centre.y;
const dz = positions[at * 3 + 2] - centre.z;
if (dx * dx + dy * dy + dz * dz <= radiusSq || ids[at] === alwaysId) {
yield stars[order[at]];
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { tdbFromUtc, ttMinusUtSeconds } from './constants';
const jd = (year: number, month = 1, day = 1): number => Date.UTC(year, month - 1, day) / 86400000 + 2440587.5;
describe('ttMinusUtSeconds', () => {
it('follows the historical record before 1972: within 1 per cent of Horizons at AD 1, 6 per cent at AD 1000, 0.2 s in 1950', () => {
// Horizons' TDB - UT (observer quantity 30) on JD 1721600, 2086455 and 2433282.5.
expect(Math.abs(ttMinusUtSeconds(1721600) - 10465.73)).toBeLessThan(105);
expect(Math.abs(ttMinusUtSeconds(2086455) - 1658.0)).toBeLessThan(100);
expect(Math.abs(ttMinusUtSeconds(2433282.5) - 28.93)).toBeLessThan(0.2);
});
it('counts the leap seconds from 1972, and holds the last from 2017 on', () => {
expect(ttMinusUtSeconds(jd(1972, 6, 30))).toBe(42.184);
expect(ttMinusUtSeconds(jd(1972, 7, 1))).toBe(43.184);
expect(ttMinusUtSeconds(jd(2016, 12, 31))).toBe(68.184);
expect(ttMinusUtSeconds(jd(2017, 1, 1))).toBe(69.184);
expect(ttMinusUtSeconds(jd(2999, 1, 1))).toBe(69.184);
});
it('joins its polynomials without a jump of more than 0.3 s, the 0.25 s at 1600 the worst', () => {
for (const year of [500, 1600, 1700, 1800, 1860, 1900, 1920, 1941, 1961]) {
const at = 2451544.5 + (year - 2000) * 365.2425;
expect(Math.abs(ttMinusUtSeconds(at + 0.01) - ttMinusUtSeconds(at - 0.01))).toBeLessThan(0.3);
}
});
it('hands over from the polynomial to the leap seconds at the start of 1972, 0.07 s apart', () => {
// The switch is placed by the calendar, not by a 365.2425-day year, so it is sampled on either
// side of midnight; the last day of 1971 must still be the polynomial's 42.25 s, not the table's
// 42.184, or the switch has moved earlier; and midnight itself must already be the table's, or it
// has moved later, which the step alone cannot see once both samples fall on the polynomial.
const start = jd(1972);
expect(ttMinusUtSeconds(start)).toBe(42.184);
expect(Math.abs(ttMinusUtSeconds(start) - ttMinusUtSeconds(start - 1e-6))).toBeLessThan(0.1);
expect(Math.abs(ttMinusUtSeconds(jd(1971, 12, 31)) - 42.2485)).toBeLessThan(0.01);
});
});
describe('tdbFromUtc', () => {
it('puts the clock’s date that far on', () => {
expect((tdbFromUtc(jd(2025)) - jd(2025)) * 86400).toBeCloseTo(69.184, 3);
expect((tdbFromUtc(2086455) - 2086455) * 86400).toBeCloseTo(ttMinusUtSeconds(2086455), 3);
});
});
+74 -21
View File
@@ -19,31 +19,84 @@ export const DEFAULT_EPOCH_JD = 2451545.0;
*/ */
export const GM_SUN_AU3_PER_DAY2 = 0.01720209895 * 0.01720209895; export const GM_SUN_AU3_PER_DAY2 = 0.01720209895 * 0.01720209895;
/** /** The first day of each month UTC took a leap second at the start of, from its 10 s of 1972. */
* Approximate planet/Sun mass ratios for the major planets that host moons in `bodies.json`. const LEAP_SECONDS_FROM = [
* Used to derive each planet's gravitational parameter (for propagating its moons) as [1972, 7], [1973, 1], [1974, 1], [1975, 1], [1976, 1], [1977, 1], [1978, 1], [1979, 1], [1980, 1], [1981, 7],
* `GM_SUN_AU3_PER_DAY2 * massRatio`. Precise enough for visualization; not JPL-grade. [1982, 7], [1983, 7], [1985, 7], [1988, 1], [1990, 1], [1991, 1], [1992, 7], [1993, 7], [1994, 7], [1996, 1],
*/ [1997, 7], [1999, 1], [2006, 1], [2009, 1], [2012, 7], [2015, 7], [2017, 1]
const PLANET_TO_SUN_MASS_RATIO: Record<string, number> = { ].map(([year, month]) => Date.UTC(year, month - 1, 1) / 86400000 + 2440587.5);
earth: 3.003e-6, const JD_1972 = Date.UTC(1972, 0, 1) / 86400000 + 2440587.5;
mars: 3.227e-7,
jupiter: 9.545e-4,
saturn: 2.857e-4,
uranus: 4.365e-5,
neptune: 5.151e-5
};
/** /**
* Gravitational parameter (AU^3/day^2) to use when propagating a body's orbit: the Sun's * TT - UT, in seconds, at a date on the map's clock: how far Earth's turning, which UT counts,
* for planets/dwarfs/exoplanets, or the host planet's (derived from its Sun mass ratio) for * has fallen behind the uniform time the ephemerides run on.
* moons. Falls back to the Sun's GM if `parentBodyId` isn't a known planet. *
* From 1972 the clock is UTC, held to within 0.9 s of UT by leap seconds, and TT - UTC is exact:
* 32.184 s plus TAI - UTC, which is the 10 s UTC started from in 1972 and the 27 leap seconds taken
* since, 37 s from 2017. After the last, at the start of 2017, it is held at 69.184 s, as Horizons
* holds it: no one knows the leap seconds to come. Before 1972 it is ΔT from the Espenak-Meeus
* polynomials (NASA's Five Millennium Canon, 2006), which fit the historical record of eclipses and
* occultations: 10 570 s at AD 1, 1 574 at AD 1000, 29 in 1950. As published they join within
* 0.26 s (at 1600; 0.16 s at 1700, under 0.09 s elsewhere), and the last meets the leap-second
* table 0.07 s apart. Held at 69 s there, as it was, every spin but Earth's was a turn of
* (ΔT - 69 s) times its rate out, 15 degrees for Jupiter at AD 1000 and 106 at AD 1, and the Moon
* 0.21 to 0.26 and 1.44 to 1.79 degrees along its orbit, as its eccentric orbit carries it faster
* or slower through those hours.
*/ */
export function gmForParent(parentBodyId: string | undefined): number { export function ttMinusUtSeconds(jdUt: number): number {
if (!parentBodyId) { if (jdUt >= JD_1972) {
return GM_SUN_AU3_PER_DAY2; return 32.184 + 10 + LEAP_SECONDS_FROM.filter((from) => jdUt >= from).length;
} }
const massRatio = PLANET_TO_SUN_MASS_RATIO[parentBodyId]; const y = 2000 + (jdUt - 2451544.5) / 365.2425;
return massRatio ? GM_SUN_AU3_PER_DAY2 * massRatio : GM_SUN_AU3_PER_DAY2; if (y < 500) {
const u = y / 100;
return 10583.6 - 1014.41 * u + 33.78311 * u ** 2 - 5.952053 * u ** 3 - 0.1798452 * u ** 4 + 0.022174192 * u ** 5 + 0.0090316521 * u ** 6;
}
if (y < 1600) {
const u = (y - 1000) / 100;
return 1574.2 - 556.01 * u + 71.23472 * u ** 2 + 0.319781 * u ** 3 - 0.8503463 * u ** 4 - 0.005050998 * u ** 5 + 0.0083572073 * u ** 6;
}
if (y < 1700) {
const t = y - 1600;
return 120 - 0.9808 * t - 0.01532 * t ** 2 + t ** 3 / 7129;
}
if (y < 1800) {
const t = y - 1700;
return 8.83 + 0.1603 * t - 0.0059285 * t ** 2 + 0.00013336 * t ** 3 - t ** 4 / 1174000;
}
if (y < 1860) {
const t = y - 1800;
return 13.72 - 0.332447 * t + 0.0068612 * t ** 2 + 0.0041116 * t ** 3 - 0.00037436 * t ** 4 + 0.0000121272 * t ** 5 - 0.0000001699 * t ** 6 + 0.000000000875 * t ** 7;
}
if (y < 1900) {
const t = y - 1860;
return 7.62 + 0.5737 * t - 0.251754 * t ** 2 + 0.01680668 * t ** 3 - 0.0004473624 * t ** 4 + t ** 5 / 233174;
}
if (y < 1920) {
const t = y - 1900;
return -2.79 + 1.494119 * t - 0.0598939 * t ** 2 + 0.0061966 * t ** 3 - 0.000197 * t ** 4;
}
if (y < 1941) {
const t = y - 1920;
return 21.2 + 0.84493 * t - 0.0761 * t ** 2 + 0.0020936 * t ** 3;
}
if (y < 1961) {
const t = y - 1950;
return 29.07 + 0.407 * t - t ** 2 / 233 + t ** 3 / 2547;
}
const t = y - 1975;
return 45.45 + 1.067 * t - t ** 2 / 260 - t ** 3 / 718;
}
/**
* The TDB date every element set here is evaluated at, for a date on the map's clock, which is
* UT: Standish's T_eph, the SSD satellite and SBDB epochs and the IAU's d and T all run on TDB.
* Positions and spins both go through this, so a locked moon's face and the orbit it is drawn on
* are taken at the same instant; taken at the clock's date, the orbits ran 69 s behind the spins,
* which is 0.9 degrees of Phobos's orbit and 0.16 of Io's.
*/
export function tdbFromUtc(jdUtc: number): number {
return jdUtc + ttMinusUtSeconds(jdUtc) / 86400;
} }
/** Converts a JS `Date` into a Julian date (days), for driving the Kepler propagator "now". */ /** Converts a JS `Date` into a Julian date (days), for driving the Kepler propagator "now". */
+43
View File
@@ -4,9 +4,11 @@ import {
distanceBetween, distanceBetween,
eclipticToEquatorial, eclipticToEquatorial,
equatorialToEcliptic, equatorialToEcliptic,
laplacePlaneToEquatorial,
OBLIQUITY_J2000_DEG, OBLIQUITY_J2000_DEG,
parallaxMasToParsecs, parallaxMasToParsecs,
parseSexagesimal, parseSexagesimal,
propagateProperMotion,
raDecDistanceToXyz, raDecDistanceToXyz,
raDecToUnitVector, raDecToUnitVector,
raDegDecDistanceToXyz raDegDecDistanceToXyz
@@ -59,6 +61,27 @@ describe('raDegDecDistanceToXyz', () => {
}); });
}); });
describe('propagateProperMotion', () => {
it("carries Barnard's Star from Gaia's epoch back to HYG's", () => {
// Gaia DR3 4472832130942575872 as published for J2016.0, moved back sixteen years with its
// own proper motion, lands on the J2000.0 position SIMBAD lists to a milliarcsecond — and
// 0.08″ from where HYG has Barnard's Star, instead of the 166″ the two epochs put between them.
const j2000 = propagateProperMotion(269.44850252543836, 4.739420051112412, -801.550978, 10362.394207, -16);
expect(j2000.raDeg).toBeCloseTo(269.4520772, 6);
expect(j2000.decDeg).toBeCloseTo(4.693365, 6);
});
it('divides the right-ascension motion by cos δ, since pmra is published on the sky', () => {
// 3600 mas/yr for one year is 3.6″ on the sky; at Dec 60° that is 7.2″ of right ascension.
expect(propagateProperMotion(0, 60, 3600, 0, 1).raDeg).toBeCloseTo(7.2 / 3600, 9);
expect(propagateProperMotion(0, 60, 0, 3600, 1).decDeg).toBeCloseTo(60 + 3.6 / 3600, 9);
});
it('leaves a star with no proper motion where it is', () => {
expect(propagateProperMotion(100, -20, 0, 0, 16)).toEqual({ raDeg: 100, decDeg: -20 });
});
});
describe('parallaxMasToParsecs', () => { describe('parallaxMasToParsecs', () => {
it('converts a positive parallax to the expected distance', () => { it('converts a positive parallax to the expected distance', () => {
expect(parallaxMasToParsecs(769.33)).toBeCloseTo(1.3, 2); // Proxima Centauri expect(parallaxMasToParsecs(769.33)).toBeCloseTo(1.3, 2); // Proxima Centauri
@@ -184,6 +207,26 @@ describe('eclipticToEquatorial', () => {
}); });
}); });
describe('laplacePlaneToEquatorial', () => {
const RAD = Math.PI / 180;
/** Jupiter's moons' Laplace pole, as JPL gives it for Io. */
const POLE = { raDeg: 268.057, decDeg: 64.495 };
it('sends the plane’s own pole to the right ascension and declination it is named by', () => {
const pole = laplacePlaneToEquatorial({ x: 0, y: 0, z: 1 }, POLE);
expect(Math.asin(pole.z) / RAD).toBeCloseTo(POLE.decDeg, 9);
expect(((Math.atan2(pole.y, pole.x) / RAD) + 360) % 360).toBeCloseTo(POLE.raDeg, 9);
});
it('counts the node from where the plane rises through the equator, 90 degrees past the pole', () => {
const node = laplacePlaneToEquatorial({ x: 1, y: 0, z: 0 }, POLE);
expect(node.z).toBeCloseTo(0, 12);
expect(((Math.atan2(node.y, node.x) / RAD) + 360) % 360).toBeCloseTo((POLE.raDeg + 90) % 360, 9);
// Rising: a quarter-turn on along the plane is north of the equator.
expect(laplacePlaneToEquatorial({ x: 0, y: 1, z: 0 }, POLE).z).toBeGreaterThan(0);
});
});
describe('equatorialToEcliptic', () => { describe('equatorialToEcliptic', () => {
it('is the exact inverse of eclipticToEquatorial', () => { it('is the exact inverse of eclipticToEquatorial', () => {
for (const point of [ for (const point of [
+40 -2
View File
@@ -33,6 +33,21 @@ export function raDegDecDistanceToXyz(raDeg: number, decDeg: number, distancePc:
return raDecDistanceToXyz(raDeg / HOURS_TO_DEG, decDeg, distancePc); return raDecDistanceToXyz(raDeg / HOURS_TO_DEG, decDeg, distancePc);
} }
const MAS_TO_DEG = 1 / 3_600_000;
/**
* Moves a sky position along its proper motion by `years` — negative to go back in time — so
* catalogues that observed at different epochs can be compared at one. `pmRaMasPerYear` is
* μα cos δ, the on-sky rate Hipparcos and Gaia both publish, hence the division by cos δ to turn
* it back into right ascension.
*/
export function propagateProperMotion(raDeg: number, decDeg: number, pmRaMasPerYear: number, pmDecMasPerYear: number, years: number): { raDeg: number; decDeg: number } {
return {
raDeg: raDeg + (years * pmRaMasPerYear * MAS_TO_DEG) / Math.cos(decDeg * DEG_TO_RAD),
decDeg: decDeg + years * pmDecMasPerYear * MAS_TO_DEG
};
}
/** /**
* Obliquity of the ecliptic at J2000.0, in degrees — the tilt of Earth's orbital plane against * Obliquity of the ecliptic at J2000.0, in degrees — the tilt of Earth's orbital plane against
* its equator, and so the angle between this app's two source frames. * its equator, and so the angle between this app's two source frames.
@@ -44,8 +59,8 @@ export const OBLIQUITY_J2000_DEG = 23.4392911;
* *
* The app has to span both because its two sources disagree. Star positions come from HYG as * The app has to span both because its two sources disagree. Star positions come from HYG as
* equatorial coordinates, which `raDecDistanceToXyz` produces and which the galaxy view renders * equatorial coordinates, which `raDecDistanceToXyz` produces and which the galaxy view renders
* directly. Orbital elements come from JPL Horizons, whose default reference plane for element * directly. The planets' and the Moon's orbital elements are JPL mean elements against the J2000
* output is the ecliptic — the ETL never overrides it. The two are tilted * ecliptic. The two are tilted
* {@link OBLIQUITY_J2000_DEG} apart about the shared vernal-equinox axis, so orbits have to be * {@link OBLIQUITY_J2000_DEG} apart about the shared vernal-equinox axis, so orbits have to be
* rotated before they can share a scene with the stars. * rotated before they can share a scene with the stars.
*/ */
@@ -63,6 +78,29 @@ export function eclipticToEquatorial(position: CartesianCoordinates): CartesianC
}; };
} }
/**
* Rotates a vector from a moon's local **Laplace plane** frame into the equatorial one.
*
* JPL gives the giant planets' moons against the plane their orbits precess about, which lies
* between the planet's equator and its orbit, and names it by its pole. The frame's x axis is
* where that plane rises through the ICRF equator, at right ascension 90 degrees past the pole's,
* which is what the node is counted from; its z axis is the pole, 90 degrees less its declination
* away from the celestial one. Read against the ecliptic instead, Io was up to 2.8 degrees from
* where Horizons has it between 1950 and 2100, Phobos 54 and Titan 127: their nodes are counted
* from a different line altogether.
*/
export function laplacePlaneToEquatorial(position: CartesianCoordinates, pole: { raDeg: number; decDeg: number }): CartesianCoordinates {
const tilt = (90 - pole.decDeg) * DEG_TO_RAD;
const node = (pole.raDeg + 90) * DEG_TO_RAD;
const y = position.y * Math.cos(tilt) - position.z * Math.sin(tilt);
const z = position.y * Math.sin(tilt) + position.z * Math.cos(tilt);
return {
x: position.x * Math.cos(node) - y * Math.sin(node),
y: position.x * Math.sin(node) + y * Math.cos(node),
z
};
}
/** Inverse of {@link eclipticToEquatorial}. */ /** Inverse of {@link eclipticToEquatorial}. */
export function equatorialToEcliptic(position: CartesianCoordinates): CartesianCoordinates { export function equatorialToEcliptic(position: CartesianCoordinates): CartesianCoordinates {
const obliquity = OBLIQUITY_J2000_DEG * DEG_TO_RAD; const obliquity = OBLIQUITY_J2000_DEG * DEG_TO_RAD;
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { extractGmKm3PerS2, extractObliquityDeg, extractRadiusKm, extractRotationPeriodHours, isTidallyLocked } from './horizons-page';
// Lines as the Horizons pages print them.
const JUPITER = ` Vol. Mean Radius (km) = 69911+-6 Flattening = 0.06487
Sid. rot. period (III)= 9h 55m 29.711 s Sid. rot. rate (rad/s)= 0.00017585`;
const MIRANDA = ` Radius (km) = 240x234.2x232.9 Density (g cm^-3) = 1.18 +- 0.05
GM (km^3/s^2) = 4.3 +- 0.2 Geometric Albedo = 0.27
Eccentricity, e = 0.0027 Rotational period = Synchronous`;
const CHARON = ` GM (km^3/s^2) = 106.10 +- 0.3 Density (g cm^-3) = 1.853 +- 0.004
Radius (km, IAU2015) = 606 +- 0.5 Geometric albedo = `;
const PLUTO = ` GM (planet) km^3/s^2 = 869.326 Density (R=1195 km) = 1.86 g/cm^3
Vol. mean radius (km) = 1188.3+-1.6 Mass ratio (Mc/Mp) = 0.122`;
const PHOEBE = ` Radius (km) = 106.6 +- 1.1 Density (g/cm^3)= 1.633 +- 0.049
Eccentricity, e = 0.1635 Rotational period = 9h 16.438 m`;
const HYPERION = ` Mean Radius (km) = 133 +- 8 Density (g/cm^3) = 0.569 +- 0.108
Eccentricity, e = 0.0232 Rotational period = Chaotic`;
describe('Horizons page radius', () => {
it('reads a volumetric mean radius', () => {
expect(extractRadiusKm(PLUTO)).toBe(1188.3);
});
it('reads the radius Charon states against IAU 2015', () => {
expect(extractRadiusKm(CHARON)).toBe(606);
});
it('gives a triaxial body the radius of the sphere of its volume, not its longest axis', () => {
// (240 × 234.2 × 232.9)^(1/3); the IAU's mean radius for Miranda is 235.8.
expect(extractRadiusKm(MIRANDA)).toBeCloseTo(235.7, 1);
// Phobos spaces its axes out; it was drawn at its longest, 13.1 km, against the IAU's 11.08.
expect(extractRadiusKm(' Radius (km) = 13.1 x11.1 x9.3 Density (g cm^-3) = 1.90')).toBeCloseTo(11.06, 2);
});
});
describe('Horizons page rotation', () => {
it('reads hours, minutes and seconds', () => {
expect(extractRotationPeriodHours(JUPITER)).toBeCloseTo(9.925, 3);
});
it('reads hours and minutes, as Phoebe states them', () => {
expect(extractRotationPeriodHours(PHOEBE)).toBeCloseTo(9 + 16.438 / 60, 6);
});
it('finds no period where the spin is chaotic', () => {
expect(extractRotationPeriodHours(HYPERION)).toBeUndefined();
expect(isTidallyLocked(HYPERION)).toBe(false);
expect(isTidallyLocked(MIRANDA)).toBe(true);
});
});
describe('Horizons page GM', () => {
it('reads a moon’s GM and Pluto’s, which are written differently', () => {
expect(extractGmKm3PerS2(CHARON)).toBe(106.1);
expect(extractGmKm3PerS2(PLUTO)).toBe(869.326);
expect(extractGmKm3PerS2(HYPERION)).toBeUndefined();
});
});
describe('Horizons page obliquity', () => {
it('reads the arcminutes Mercury gives its tilt in, and the degrees every other page uses', () => {
expect(extractObliquityDeg(" Obliquity to orbit[1] = 2.11' +/- 0.1' Hill's sphere rad. Rp = 94.4 ")).toBeCloseTo(2.11 / 60, 9);
expect(extractObliquityDeg(' Obliquity to orbit = 25.19 deg Max. angular diam. = 17.9"')).toBe(25.19);
});
});
+107
View File
@@ -0,0 +1,107 @@
/**
* Reads the physical-data block at the top of a JPL Horizons object page, which the ETL fetches
* (see `tools/etl/lib/horizons.ts`). Every page is written by hand, so each quantity is stated in
* several ways; the patterns below are the ones the bodies in `bodies.json` actually use.
*/
/**
* What follows the `=`: one radius, or a triaxial body's three semi-axes as `240x234.2x232.9`,
* as Miranda's and Ariel's pages give them.
*/
const RADIUS_VALUE = String.raw`=\s*([\d.]+(?:\s*x\s*[\d.]+)*)`;
const RADIUS_PATTERNS = [
new RegExp(String.raw`Vol\.?\s*mean\s*radius[^=]*${RADIUS_VALUE}`, 'i'),
new RegExp(String.raw`Mean\s*radius[^=]*${RADIUS_VALUE}`, 'i'),
new RegExp(String.raw`Radius\s*\(IAU\)[^=]*${RADIUS_VALUE}`, 'i'),
// Charon's page says `Radius (km, IAU2015) = 606`.
new RegExp(String.raw`Radius,?\s*\(km(?:,\s*IAU\s*2015)?\)\s*${RADIUS_VALUE}`, 'i'),
new RegExp(String.raw`Radius\s*\(gravity\),?\s*km\s*${RADIUS_VALUE}`, '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 and minutes comes next, as the
* giant planets and Phoebe state it, then one in hours or days, 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;
/**
* `9h 55m 29.711 s`, as Jupiter and Saturn state it, and `9h 16.438 m`, as Phoebe does: read as
* a period in hours alone, Phoebe turned once in 9 hours instead of 9.274.
*/
const SEXAGESIMAL_ROTATION_PATTERN = /(?:Sid(?:ereal|\.)?\s*rot\.?|Rotation(?:al)?)\s*period[^=]*=\s*(\d+)\s*h\s*([\d.]+)\s*m(?:\s*([\d.]+)\s*s)?/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
];
const SYNCHRONOUS_PATTERN = /Rotation(?:al)?\s*period\s*=?\s*:?\s*Synchronous/i;
/** `25.19 deg` on most pages, `2.11' +/- 0.1'` in arcminutes on Mercury's. */
const OBLIQUITY_PATTERN = /Obliquity\s*to\s*orbit[^=]*=\s*(-?[\d.]+)\s*(')?/i;
/** `GM (km^3/s^2) = 106.10` on a moon's page, `GM (planet) km^3/s^2 = 869.326` on Pluto's. */
const GM_PATTERN = /GM\s*(?:\(planet\)\s*)?,?\s*\(?km\^3\/s\^2\)?\s*=\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 the orbit's, which the caller takes from the body's
* mean motion.
*/
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] ?? 0) / 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;
}
/**
* Tilt of the rotation axis from the orbit, in degrees. Mercury's page gives its tilt in
* arcminutes, which read as degrees made it 2.11 where the IAU's pole puts it at 0.034.
*/
export function extractObliquityDeg(text: string): number | undefined {
const match = text.match(OBLIQUITY_PATTERN);
return match ? Number(match[1]) / (match[2] ? 60 : 1) : undefined;
}
/**
* Mean radius in km. For a triaxial body, the radius of the sphere of the same volume, the cube
* root of the three semi-axes' product, which is how the IAU states a mean radius: Miranda's
* 240 x 234.2 x 232.9 km is 235.7, where the first figure alone overstated it by 2 per cent.
*/
export function extractRadiusKm(text: string): number | undefined {
for (const pattern of RADIUS_PATTERNS) {
const match = text.match(pattern);
if (match) {
const axes = match[1].split('x').map(Number);
return axes.reduce((product, axis) => product * axis, 1) ** (1 / axes.length);
}
}
return undefined;
}
/** The body's own GM, in km³/s², where the page publishes one. */
export function extractGmKm3PerS2(text: string): number | undefined {
const match = text.match(GM_PATTERN);
return match ? Number(match[1]) : undefined;
}
+142 -33
View File
@@ -1,9 +1,14 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { buildStarNameIndex, normalizeStarName, resolveHostStarId } from './host-star-matching'; import { buildStarNameIndex, normalizeStarName, resolveHostStarId } from './host-star-matching';
import { propagateProperMotion, raDegDecDistanceToXyz } from './coordinates';
import { StarRecord } from '../models/star.model'; import { StarRecord } from '../models/star.model';
// A small fixture standing in for a slice of the HYG star index, used to exercise the function star(id: number, name: string, raDeg: number, decDeg: number, distancePc: number): StarRecord {
return { id, name, ...raDegDecDistanceToXyz(raDeg, decDeg, distancePc), magnitude: 10, spectralType: 'M', colorIndex: 1.0 };
}
// A small fixture standing in for a slice of the star catalogue, used to exercise the
// exoplanet host-star cross-referencing logic without hitting any real API. // exoplanet host-star cross-referencing logic without hitting any real API.
const FIXTURE_STARS: StarRecord[] = [ const FIXTURE_STARS: StarRecord[] = [
// The Sun sits at the origin, exactly where a host with a missing distance lands. // The Sun sits at the origin, exactly where a host with a missing distance lands.
@@ -22,46 +27,150 @@ describe('normalizeStarName', () => {
describe('resolveHostStarId', () => { describe('resolveHostStarId', () => {
it('matches by exact (normalized) host star name', () => { it('matches by exact (normalized) host star name', () => {
const id = resolveHostStarId({ hostname: 'Proxima Centauri', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS, 0.5); const id = resolveHostStarId({ hostname: 'Proxima Centauri', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS);
expect(id).toBe(1); expect(id).toBe(1);
}); });
it('matches by name regardless of case/spacing differences', () => { it('matches by name regardless of case/spacing differences', () => {
const id = resolveHostStarId({ hostname: 'gj3512', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS, 0.5); const id = resolveHostStarId({ hostname: 'gj3512', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS);
expect(id).toBe(3); expect(id).toBe(3);
}); });
it('falls back to nearest-neighbour position matching when the name is unknown', () => { it('returns null when there is no name match and no position is available', () => {
// Slightly off from Sirius's exact position, within tolerance. const id = resolveHostStarId({ hostname: 'Unknown Star XYZ', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS);
const id = resolveHostStarId({ hostname: 'Sirius A', raDeg: 101.29, decDeg: -16.72, distancePc: 2.64 }, FIXTURE_STARS, 0.5);
expect(id).toBeNull();
});
describe('matching on the sky', () => {
// GJ 887's archive row: position at Gaia's epoch, carried by 6.9″/yr of proper motion —
// 110″ from where the catalogue has the star at J2000. The matcher must carry the query
// back those sixteen years itself, and judge each star on the better of the two epochs: the
// decoy standing halfway along the star's own track is nearer than Lacaille 9352 at the
// published point *and* nearer at the worse of the two epochs, so it wins unless the
// carried-back position is tried and the best epoch — not the worst — decides.
it('matches a host published at the Gaia epoch to its star at J2000, past a decoy on its track', () => {
const lacaille9352 = star(70, 'Lacaille 9352', 346.46683, -35.85306, 3.29);
const archive = propagateProperMotion(346.46683, -35.85306, 6768.2, 1327.52, 16);
const decoy = star(71, 'Decoy', (346.46683 + archive.raDeg) / 2, (-35.85306 + archive.decDeg) / 2, 3.29);
const id = resolveHostStarId(
{ hostname: 'GJ 887', raDeg: archive.raDeg, decDeg: archive.decDeg, distancePc: 3.28679, pmRaMasPerYear: 6768.2, pmDecMasPerYear: 1327.52 },
[decoy, lacaille9352]
);
expect(id).toBe(70);
});
// alf Tau's archive row publishes J2000 outright, and the archive never says which epoch a
// row is at. If the matcher trusted one epoch and carried every query back, Aldebaran's
// planet would land on the Gliese entry sitting 3″ from the carried-back point; the raw
// position, zero arcseconds from Aldebaran itself, has to win.
it('keeps a host published at J2000 on its star, proper motion or not', () => {
const aldebaran = star(80, 'Aldebaran', 68.980163, 16.509302, 20.43);
const carried = propagateProperMotion(68.980163, 16.509302, 63, -189, -16);
const ghost = star(81, 'Gl 171.1B', carried.raDeg, carried.decDeg + 3 / 3600, 20.43);
const id = resolveHostStarId(
{ hostname: 'alf Tau', raDeg: 68.980163, decDeg: 16.509302, distancePc: 20.43, pmRaMasPerYear: 63, pmDecMasPerYear: -189 },
[ghost, aldebaran]
);
expect(id).toBe(80);
});
// GJ 15 A's archive row sits at J2016, 46″ along its proper motion from Groombridge 34's
// J2000 place — and only 16″ from an unrelated Gaia entry. Nearest-to-the-published-point
// picks the interloper; carrying the query back the sixteen years must put the planets on
// the star that actually moved there.
it('picks the star the proper motion says the query is, not the entry nearest the published point', () => {
const primary = star(90, 'Groombridge 34', 4.595364, 44.022955, 3.562);
const published = propagateProperMotion(4.595364, 44.022955, 2891.5, 411.9, 16);
const interloper = star(91, 'Gaia DR3 385334196532776576', published.raDeg, published.decDeg + 16 / 3600, 3.563);
const id = resolveHostStarId(
{ hostname: 'GJ 15 A', raDeg: published.raDeg, decDeg: published.decDeg, distancePc: 3.56228, pmRaMasPerYear: 2891.5, pmDecMasPerYear: 411.9 },
[interloper, primary]
);
expect(id).toBe(90);
});
// GJ 273 is Luyten's Star to the arcsecond, but the archive publishes 5.92 pc for a star
// at 3.79 — a 56% disagreement. Direction alone must not override a distance in flat
// contradiction, or every line-of-sight coincidence becomes a match.
it('refuses a host whose distance flatly contradicts the star it points at', () => {
const luytens = star(100, "Luyten's Star", 111.8496, 5.2258, 3.79);
const id = resolveHostStarId({ hostname: 'GJ 273', raDeg: 111.8496, decDeg: 5.2258, distancePc: 5.921535 }, [luytens]);
expect(id).toBeNull();
});
// The tolerance is transverse — parsecs on the sky, not an angle — so the same 15″ offset
// is a match at 50 pc and a stranger at 200 pc.
it('scales the angular tolerance with the host distance', () => {
const at200 = resolveHostStarId(
{ hostname: 'Unmatched', raDeg: 150, decDeg: -40 + 15 / 3600, distancePc: 200 },
[star(110, 'Far', 150, -40, 200)]
);
const at50 = resolveHostStarId(
{ hostname: 'Unmatched', raDeg: 150, decDeg: -40 + 15 / 3600, distancePc: 50 },
[star(111, 'Near', 150, -40, 50)]
);
expect(at200).toBeNull();
expect(at50).toBe(111);
});
// A star whose distance disqualifies it is not merely rejected — it must not become the
// best-so-far either, or an unmerged twin with a bad parallax, sitting nearer on the sky
// than the true host, silently unhosts the planet by outranking a star that is never
// allowed to win.
it('does not let a star its distance disqualifies shadow the true host behind it', () => {
const badParallaxTwin = star(120, 'Gaia DR3 twin', 40, 12 + 1 / 3600, 480);
const host = star(121, 'True host', 40, 12 + 3 / 3600, 100);
const id = resolveHostStarId({ hostname: 'Unmatched', raDeg: 40, decDeg: 12, distancePc: 100 }, [badParallaxTwin, host]);
expect(id).toBe(121);
});
// A proper motion that is not a number must not poison the comparison: NaN loses every
// `<` it appears in, so an unguarded one lets each star past the direction test and hands
// the planet to whichever happens to be last in the catalogue.
it('treats an unusable proper motion as no motion rather than matching by array order', () => {
const pointedAt = star(130, 'Pointed at', 10, 10, 5);
const acrossTheSky = star(131, 'Across the sky', 190, -10, 5);
const id = resolveHostStarId(
{ hostname: 'Unmatched', raDeg: 10, decDeg: 10, distancePc: 5, pmRaMasPerYear: NaN, pmDecMasPerYear: 0 },
[pointedAt, acrossTheSky]
);
expect(id).toBe(130);
});
// Normalizing strips the dot, so `Gl 55.2` and `Gl 552` — two stars 135° apart — answer to
// one key. A name that names both names neither: the sky has to settle it.
it('sends a name two stars answer to back to the sky', () => {
const gl552 = star(140, 'Gl 552', 217.0, 15.0, 14.2);
const gl55dot2 = star(141, 'Gl 55.2', 30.0, -20.0, 23.9);
const id = resolveHostStarId({ hostname: 'Gl 552', raDeg: 217.0, decDeg: 15.0, distancePc: 14.2 }, [gl552, gl55dot2]);
expect(id).toBe(140);
});
it('reuses a prebuilt name index when given one', () => {
const nameIndex = buildStarNameIndex(FIXTURE_STARS);
const id = resolveHostStarId({ hostname: 'Sirius', raDeg: NaN, decDeg: NaN, distancePc: NaN }, [], nameIndex);
expect(id).toBe(2); expect(id).toBe(2);
}); });
it('returns null when no name match and no star is within tolerance', () => {
const id = resolveHostStarId({ hostname: 'Unknown Star XYZ', raDeg: 0, decDeg: 0, distancePc: 100 }, FIXTURE_STARS, 0.5);
expect(id).toBeNull();
});
it('returns null when there is no name match and no position is available', () => {
const id = resolveHostStarId({ hostname: 'Unknown Star XYZ', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS, 0.5);
expect(id).toBeNull();
});
it('picks the closest star when more than one falls within tolerance', () => {
const stars: StarRecord[] = [
{ id: 10, name: 'Near', x: 0, y: 0, z: 0, magnitude: 5, spectralType: 'G', colorIndex: 0.5 },
{ id: 11, name: 'Far', x: 0.4, y: 0, z: 0, magnitude: 5, spectralType: 'G', colorIndex: 0.5 }
];
const nameIndex = buildStarNameIndex(stars);
const id = resolveHostStarId({ hostname: 'Unmatched', raDeg: 0, decDeg: 0, distancePc: 0.2 }, stars, 0.5, nameIndex);
expect(id).toBe(10);
}); });
describe('missing distance column', () => { describe('missing distance column', () => {
@@ -70,25 +179,25 @@ describe('resolveHostStarId', () => {
// the Sun at distance 0. That shipped 127 alien planets, all seven TRAPPIST-1 worlds among // the Sun at distance 0. That shipped 127 alien planets, all seven TRAPPIST-1 worlds among
// them, into our own solar system. // them, into our own solar system.
it('does not match a host with a zero distance to the Sun', () => { it('does not match a host with a zero distance to the Sun', () => {
const id = resolveHostStarId({ hostname: 'TRAPPIST-1', raDeg: 346.6, decDeg: -5.04, distancePc: 0 }, FIXTURE_STARS, 0.5); const id = resolveHostStarId({ hostname: 'TRAPPIST-1', raDeg: 346.6, decDeg: -5.04, distancePc: 0 }, FIXTURE_STARS);
expect(id).toBeNull(); expect(id).toBeNull();
}); });
it('rejects a negative distance too', () => { it('rejects a negative distance too', () => {
const id = resolveHostStarId({ hostname: 'Nowhere', raDeg: 10, decDeg: 10, distancePc: -3 }, FIXTURE_STARS, 0.5); const id = resolveHostStarId({ hostname: 'Nowhere', raDeg: 10, decDeg: 10, distancePc: -3 }, FIXTURE_STARS);
expect(id).toBeNull(); expect(id).toBeNull();
}); });
it('still matches a real host at a genuinely small distance', () => { it('still matches a real host at a genuinely small distance', () => {
const id = resolveHostStarId({ hostname: 'Unmatched', raDeg: 217.4, decDeg: -62.68, distancePc: 1.2959 }, FIXTURE_STARS, 0.5); const id = resolveHostStarId({ hostname: 'Unmatched', raDeg: 217.4, decDeg: -62.68, distancePc: 1.2959 }, FIXTURE_STARS);
expect(id).toBe(1); expect(id).toBe(1);
}); });
it('lets a named host resolve even with no usable distance', () => { it('lets a named host resolve even with no usable distance', () => {
const id = resolveHostStarId({ hostname: 'Sirius', raDeg: 101.3, decDeg: -16.7, distancePc: 0 }, FIXTURE_STARS, 0.5); const id = resolveHostStarId({ hostname: 'Sirius', raDeg: 101.3, decDeg: -16.7, distancePc: 0 }, FIXTURE_STARS);
expect(id).toBe(2); expect(id).toBe(2);
}); });
+112 -87
View File
@@ -1,5 +1,5 @@
import { CartesianCoordinates, distanceBetween, raDegDecDistanceToXyz } from './coordinates'; import { propagateProperMotion, raDegDecDistanceToXyz } from './coordinates';
import { ExoplanetRecord } from '../models/exoplanet.model'; import { MERGE_DISTANCE_RATIO_TOLERANCE } from './star-merge';
import { StarRecord } from '../models/star.model'; import { StarRecord } from '../models/star.model';
/** Normalizes a star name for comparison: lowercase, alphanumeric characters only. */ /** Normalizes a star name for comparison: lowercase, alphanumeric characters only. */
@@ -12,25 +12,92 @@ export interface HostStarQuery {
raDeg: number; raDeg: number;
decDeg: number; decDeg: number;
distancePc: number; distancePc: number;
} /** μα·cos δ in mas/yr, as the archive publishes it (`sy_pmra`); missing means unknown. */
pmRaMasPerYear?: number;
/** Builds a lookup of normalized star name -> star, for fast repeated name matching. */ pmDecMasPerYear?: number;
export function buildStarNameIndex(stars: readonly StarRecord[]): Map<string, StarRecord> {
return new Map(stars.map((star) => [normalizeStarName(star.name), star]));
} }
/** /**
* Cross-references an exoplanet host star to the HYG star index: first by (normalized) * Builds a lookup of normalized star name -> star, for fast repeated name matching.
* name, then by nearest-neighbour position matching within `toleranceInPc`. Returns `null` *
* A name two stars answer to names neither: normalizing strips the dot, so `Gl 55.2` and
* `Gl 552` — 135° apart, and 64 such groups exist in the catalogue — collide on `gl552`, and a
* map would silently keep whichever came last. Ambiguous keys are dropped instead, which sends
* the query to the sky, where direction settles it.
*/
export function buildStarNameIndex(stars: readonly StarRecord[]): Map<string, StarRecord> {
const index = new Map<string, StarRecord>();
const ambiguous = new Set<string>();
for (const star of stars) {
const key = normalizeStarName(star.name);
if (index.has(key)) {
ambiguous.add(key);
} else {
index.set(key, star);
}
}
for (const key of ambiguous) {
index.delete(key);
}
return index;
}
/**
* How far, on the sky, a host may sit from a catalogue star and still be the same object —
* expressed as a transverse offset in parsecs (separation angle × the host's distance), not as
* an angle.
*
* The offset between the archive's position and ours is dominated by proper motion over an
* epoch difference, and that is a *physical* displacement: velocity × time, the same in parsecs
* at any distance. As an angle it is anything — Proxima's two positions are 60″ apart, a host at
* 100 pc moves under 2″ — so a fixed angle either loses the near, fast stars or drowns the far
* ones in neighbours. In parsecs the bound is one number: 25 years of an extreme 200 km/s
* transverse velocity is 5·10⁻³ pc.
*
* Measured on the 504 hosts whose archive name matches a catalogue name outright — true pairs,
* matched without coordinates: their transverse offset reaches 3.4·10⁻³ pc (5.0·10⁻³ before the
* epoch straddle below) and 0.01 pc doubles that. Chance stays out of reach: shifting every
* host a quarter of a degree finds nothing within the budget except Proxima's own entry, whose
* budget at 1.3 pc is wider than the shift itself.
*/
export const HOST_TRANSVERSE_TOLERANCE_PC = 0.01;
/**
* The archive does not say which epoch a row's position is for, and they are demonstrably
* mixed: alf Tau and GJ 273 publish J2000 (the raw position sits under an arcsecond from our
* star, and carrying it back doubles the error), HD 133131 and TOI-2459 publish Gaia's J2016
* (the carried-back position lands to 0.1″). So every query is tried at both ends — as
* published, and carried back sixteen years with the archive's own proper motion — and a star
* is judged on whichever is closer. Guessing one epoch picks companions: assume J2016 and
* Aldebaran's planet lands on Gl 171.1B, assume J2000 and GJ 15 A's land on a Gaia entry
* 15.9″ out.
*/
const CATALOGUE_EPOCH = 2000.0;
const ARCHIVE_LATEST_EPOCH = 2016.0;
function knownMotion(masPerYear: number | undefined): number {
return Number.isFinite(masPerYear) ? (masPerYear as number) : 0;
}
/**
* Cross-references an exoplanet host star to the star catalogue: first by (normalized) name,
* then on the sky — the nearest star within {@link HOST_TRANSVERSE_TOLERANCE_PC} whose distance
* does not flatly contradict the archive's ({@link MERGE_DISTANCE_RATIO_TOLERANCE}, shared with
* the catalogue merge, which faces the same Hipparcos-vs-Gaia disagreements). Returns `null`
* when neither approach finds a confident match, rather than guessing. * when neither approach finds a confident match, rather than guessing.
* *
* Identity lives in the direction, exactly as in `star-merge.ts`: the previous rule — nearest
* neighbour within half a parsec in 3D — turned into a ten-arcminute cone at 170 pc, handing
* planets of stars our catalogue does not contain to whatever bright star floated nearest
* (HATS-6 to HD 39500), while a 1 pc distance disagreement at 60 pc unhosted four bright
* giants' planets whose directions matched to two arcseconds.
*
* `nameIndex` should be built once (via {@link buildStarNameIndex}) and reused across calls * `nameIndex` should be built once (via {@link buildStarNameIndex}) and reused across calls
* when resolving many queries against the same star list. * when resolving many queries against the same star list.
*/ */
export function resolveHostStarId( export function resolveHostStarId(
query: HostStarQuery, query: HostStarQuery,
stars: readonly StarRecord[], stars: readonly StarRecord[],
toleranceInPc: number,
nameIndex: Map<string, StarRecord> = buildStarNameIndex(stars) nameIndex: Map<string, StarRecord> = buildStarNameIndex(stars)
): number | null { ): number | null {
const byName = nameIndex.get(normalizeStarName(query.hostname)); const byName = nameIndex.get(normalizeStarName(query.hostname));
@@ -43,91 +110,49 @@ export function resolveHostStarId(
} }
// A non-positive distance is never a real measurement, and it is the specific shape a // A non-positive distance is never a real measurement, and it is the specific shape a
// missing CSV cell takes: `Number('')` is `0`, which passes the finiteness check above and // missing CSV cell takes: `Number('')` is `0`. Without a believable distance there is no
// then places the host exactly at the origin — where it matches the Sun at distance 0 and // transverse budget and no ratio test, so the position cannot speak.
// hands an alien planet to our own solar system.
if (query.distancePc <= 0) { if (query.distancePc <= 0) {
return null; return null;
} }
const hostPosition = raDegDecDistanceToXyz(query.raDeg, query.decDeg, query.distancePc); const published = raDegDecDistanceToXyz(query.raDeg, query.decDeg, 1);
return findNearestStarWithin(hostPosition, stars, toleranceInPc); const carriedBack = propagateProperMotion(
} query.raDeg,
query.decDeg,
function findNearestStarWithin(position: CartesianCoordinates, stars: readonly StarRecord[], toleranceInPc: number): number | null { // A proper motion that is not a number must read as "stands still", not poison the
let closest: { id: number; distance: number } | null = null; // comparison: one NaN makes every star's cosine NaN, and `NaN < min` is false, so every
// star would pass the direction test and the last one in array order would win.
for (const star of stars) { knownMotion(query.pmRaMasPerYear),
const distance = distanceBetween(position, star); knownMotion(query.pmDecMasPerYear),
if (distance <= toleranceInPc && (!closest || distance < closest.distance)) { CATALOGUE_EPOCH - ARCHIVE_LATEST_EPOCH
closest = { id: star.id, distance };
}
}
return closest ? closest.id : null;
}
/**
* Re-resolves every exoplanet's host star against a star catalogue.
*
* The cross-reference is a *derived* fact: it depends as much on which stars were loaded as on
* the archive itself. When the catalogue reached 50 pc, 388 of the archive's 4735 named hosts
* found a match and the other 4347 were carried and never drawn — not because their planets are
* unknown, but because their star was out of range. Widening the catalogue rescues some of them,
* and until the host coordinates were stored alongside each planet that meant re-downloading an
* archive which is not always reachable.
*
* Records written before those coordinates were kept can still be matched *by name*, which needs
* no coordinates at all — and that alone is worth doing, because a wider catalogue contains more
* names. What such a record cannot do is disprove its existing match: a name miss means only
* that the name missed, not that the star is absent. So those are upgraded where a match is
* found and left alone otherwise, while records that do carry coordinates take the new result
* outright, match or no match.
*/
/** A host must sit within this many parsecs of a catalogue star to count as the same object. */
export const HOST_MATCH_TOLERANCE_PC = 2;
export interface RematchSummary {
total: number;
/** Records carrying host coordinates, and therefore eligible to be re-matched in full. */
resolvable: number;
matched: number;
gained: number;
lost: number;
}
export function rematchHostStars(exoplanets: ExoplanetRecord[], stars: readonly StarRecord[]): RematchSummary {
const nameIndex = buildStarNameIndex(stars);
const summary: RematchSummary = { total: exoplanets.length, resolvable: 0, matched: 0, gained: 0, lost: 0 };
for (const exoplanet of exoplanets) {
const { hostRaDeg, hostDecDeg, hostDistancePc } = exoplanet;
const positioned = hostRaDeg !== undefined && hostDecDeg !== undefined && hostDistancePc !== undefined;
if (positioned) {
summary.resolvable++;
}
const previous = exoplanet.hostStarId;
// With no coordinates the query still carries the host's name, and `resolveHostStarId` tries
// that first; the positional fallback simply declines to run on non-finite coordinates.
const resolved = resolveHostStarId(
{ hostname: exoplanet.hostStarName, raDeg: hostRaDeg ?? Number.NaN, decDeg: hostDecDeg ?? Number.NaN, distancePc: hostDistancePc ?? Number.NaN },
stars,
HOST_MATCH_TOLERANCE_PC,
nameIndex
); );
const carried = raDegDecDistanceToXyz(carriedBack.raDeg, carriedBack.decDeg, 1);
exoplanet.hostStarId = positioned ? resolved : (resolved ?? previous); const minCosine = Math.cos(Math.min(Math.PI, HOST_TRANSVERSE_TOLERANCE_PC / query.distancePc));
if (exoplanet.hostStarId !== null) { let best: StarRecord | null = null;
summary.matched++; let bestCosine = -2;
for (const star of stars) {
const starDistance = Math.hypot(star.x, star.y, star.z);
// The Sun sits at the origin and has no direction to compare; every real host is elsewhere.
if (starDistance === 0) {
continue;
} }
if (previous === null && exoplanet.hostStarId !== null) { const cosine =
summary.gained++; Math.max(
} else if (previous !== null && exoplanet.hostStarId === null) { star.x * published.x + star.y * published.y + star.z * published.z,
summary.lost++; star.x * carried.x + star.y * carried.y + star.z * carried.z
) / starDistance;
if (cosine < minCosine || cosine <= bestCosine) {
continue;
} }
const [near, far] = query.distancePc < starDistance ? [query.distancePc, starDistance] : [starDistance, query.distancePc];
if ((far - near) / near > MERGE_DISTANCE_RATIO_TOLERANCE) {
continue;
}
best = star;
bestCosine = cosine;
} }
return summary; return best ? best.id : null;
} }
@@ -1,82 +0,0 @@
import { describe, expect, it } from 'vitest';
import { ExoplanetRecord } from '../models/exoplanet.model';
import { StarRecord } from '../models/star.model';
import { rematchHostStars } from './host-star-matching';
/** Two catalogue stars, one of which is only present in the wider of the two catalogues. */
const NEARBY: StarRecord = { id: 100, name: 'Gl 357', x: 9, y: 0, z: 0, magnitude: 10.9, spectralType: 'K', colorIndex: 1.4 };
const DISTANT: StarRecord = { id: 200, name: 'HD 33844', x: 0, y: 120, z: 0, magnitude: 7.7, spectralType: 'K0', colorIndex: 1.0 };
const NARROW_CATALOGUE = [NEARBY];
const WIDE_CATALOGUE = [NEARBY, DISTANT];
function planet(overrides: Partial<ExoplanetRecord> = {}): ExoplanetRecord {
return { id: 'p', hostStarId: null, hostStarName: 'HD 33844', name: 'HD 33844 b', orbit: { semiMajorAxisAu: 1 }, ...overrides };
}
describe('rematchHostStars', () => {
it('rescues a host that the wider catalogue now contains, by name alone', () => {
// The whole point: the cross-reference is a fact about the catalogue as much as about the
// archive, so widening one ought to resolve hosts the other already knew about.
const planets = [planet()];
const summary = rematchHostStars(planets, WIDE_CATALOGUE);
expect(planets[0].hostStarId).toBe(DISTANT.id);
expect(summary.gained).toBe(1);
expect(summary.matched).toBe(1);
});
it('needs no coordinates to do it', () => {
// Which matters, because the shipped records were written before coordinates were kept.
const planets = [planet()];
expect(planets[0].hostRaDeg).toBeUndefined();
rematchHostStars(planets, WIDE_CATALOGUE);
expect(planets[0].hostStarId).toBe(DISTANT.id);
});
it('will not clear an existing match on a name miss when it has no coordinates', () => {
// A name miss says the name missed, not that the star is absent — and the earlier match may
// have been positional, from data this record no longer carries.
const planets = [planet({ hostStarId: 999, hostStarName: 'Some Survey Designation' })];
const summary = rematchHostStars(planets, WIDE_CATALOGUE);
expect(planets[0].hostStarId).toBe(999);
expect(summary.lost).toBe(0);
expect(summary.matched).toBe(1);
});
it('takes the new answer outright when the record does carry coordinates', () => {
// With coordinates the match can be redone in full, so its result is authoritative — a host
// that no longer resolves is cleared rather than left pointing at a star that may be gone.
const planets = [planet({ hostStarId: 999, hostStarName: 'Nowhere', hostRaDeg: 10, hostDecDeg: 10, hostDistancePc: 500 })];
const summary = rematchHostStars(planets, WIDE_CATALOGUE);
expect(planets[0].hostStarId).toBeNull();
expect(summary.resolvable).toBe(1);
expect(summary.lost).toBe(1);
});
it('matches a positioned host to the catalogue star at its coordinates', () => {
const planets = [planet({ hostStarName: 'unlisted alias', hostRaDeg: 90, hostDecDeg: 0, hostDistancePc: 120 })];
rematchHostStars(planets, WIDE_CATALOGUE);
expect(planets[0].hostStarId).toBe(DISTANT.id);
});
it('leaves a host that neither catalogue contains unmatched', () => {
const planets = [planet()];
const summary = rematchHostStars(planets, NARROW_CATALOGUE);
expect(planets[0].hostStarId).toBeNull();
expect(summary.matched).toBe(0);
expect(summary.gained).toBe(0);
});
it('counts every record it was given', () => {
const planets = [planet(), planet({ id: 'q', hostStarName: 'Gl 357' }), planet({ id: 'r', hostStarName: 'nobody' })];
const summary = rematchHostStars(planets, WIDE_CATALOGUE);
expect(summary.total).toBe(3);
expect(summary.matched).toBe(2);
});
});
+387
View File
@@ -0,0 +1,387 @@
import { describe, expect, it } from 'vitest';
import { jumpLinkSegments, minimumRangeBetween, routeBetween } from './jump-links';
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
/** Stars a parsec apart along x, so a chain's length is the number of hops it takes. */
function chain(count: number): StarNeighbourhood {
return new StarNeighbourhood(Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 })));
}
function index(points: StarPoint[]): StarNeighbourhood {
return new StarNeighbourhood(points);
}
describe('routeBetween', () => {
it('walks the chain a hop at a time when that is all the range allows', () => {
const { route } = routeBetween(chain(5), 0, 4, 1.5);
expect(route?.stars).toEqual([0, 1, 2, 3, 4]);
expect(route?.totalPc).toBeCloseTo(4);
expect(route?.longestHopPc).toBeCloseTo(1);
});
it('goes straight there when the range reaches, however many stars lie between', () => {
// The direct crossing is never longer than a chain through anything — Euclid says so — so a
// range that covers it makes it the answer, and the stars in between are just scenery.
const { route } = routeBetween(chain(5), 0, 4, 5);
expect(route?.stars).toEqual([0, 4]);
expect(route?.totalPc).toBeCloseTo(4);
});
it('picks the shorter of two ways round when neither is a straight line', () => {
// 0 to 3 is 10 pc, out of a 6 pc range. Two ways round, both inside it: through 1, barely
// off the line, or through 2, well off it. Shorter is what "the way there" means.
const { route } = routeBetween(
index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 5, y: 0.5, z: 0 },
{ id: 2, x: 5, y: 3, z: 0 },
{ id: 3, x: 10, y: 0, z: 0 }
]),
0,
3,
6
);
expect(route?.stars).toEqual([0, 1, 3]);
expect(route?.totalPc).toBeCloseTo(10.05, 1);
});
it('finds nothing across a gap wider than the range', () => {
const split = index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 1, y: 0, z: 0 },
{ id: 2, x: 20, y: 0, z: 0 }
]);
expect(routeBetween(split, 0, 2, 5)).toEqual({ route: null, gaveUp: false });
});
it('answers nothing for a star that is not there, or for going nowhere', () => {
const line = chain(3);
expect(routeBetween(line, 0, 0, 2).route).toBeNull();
expect(routeBetween(line, 0, 99, 2).route).toBeNull();
expect(routeBetween(line, 0, 2, 0).route).toBeNull();
});
it('reports the longest hop, which is what the range has to cover', () => {
const { route } = routeBetween(
index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 1, y: 0, z: 0 },
{ id: 2, x: 5, y: 0, z: 0 }
]),
0,
2,
4
);
expect(route?.longestHopPc).toBeCloseTo(4);
});
it('says it gave up rather than that there is no chain, once it has spent its budget', { timeout: 30_000 }, () => {
// Nothing reaches the island, but the crowd around the departure is larger than the budget, so
// the search stops without having looked everywhere the range reaches. Read as "no chain", that
// is a confident wrong answer — and the range search downstream would build on it.
// Cells sized for the range asked of them, as the real catalogue's are: a search that settles
// 40 000 stars scans every cell it touches 40 000 times.
const search = routeBetween(knotAndChain(1.5), 0, ISLAND, 1.5);
expect(search.route).toBeNull();
expect(search.gaveUp).toBe(true);
});
it('reports a dead end proved with the last star of the budget as a dead end, not a give-up', () => {
// Exactly the budget's worth of stars reach each other, and the destination is not among them.
// The search does look everywhere the range reaches, so "no chain" is what it found — but the
// set is full at the end of it, and a budget read off the settled count says it gave up.
const search = routeBetween(budgetExactly(), 0, BUDGET_ISLAND, 1.5);
expect(search).toEqual({ route: null, gaveUp: false });
});
it('heads for the destination rather than exhausting a dense knot around the departure', () => {
// The Gaia catalogue in miniature: a crowd around the departure, larger than the search's
// budget, with the only way on a thin chain leading out of it. A search widening evenly from
// the departure spends the budget on the crowd and never reaches the chain's far end.
const { route } = routeBetween(knotAndChain(), 0, CHAIN_END, 1.5);
expect(route).not.toBeNull();
expect(route!.stars[route!.stars.length - 1]).toBe(CHAIN_END);
expect(route!.longestHopPc).toBeLessThanOrEqual(1.5);
});
});
/**
* 45 000 stars scattered through the 30 pc cube around the origin, twenty times the density around
* the real Sun and more than a search's budget, with a chain a parsec a hop running along x from
* the origin out through the crowd and on to 75 pc — and one star at 500 pc that nothing reaches.
*/
const CHAIN_END = 75;
const ISLAND = 999;
function knotAndChain(cellSizePc?: number): StarNeighbourhood {
let seed = 7;
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 30 - 15;
const knot: StarPoint[] = Array.from({ length: 45000 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
const chainOut: StarPoint[] = Array.from({ length: CHAIN_END }, (_, i) => ({ id: i + 1, x: i + 1, y: 0, z: 0 }));
return new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...knot, ...chainOut, { id: ISLAND, x: 500, y: 0, z: 0 }], cellSizePc);
}
/**
* Exactly a search's budget of stars that reach one another — 40 000 a parsec apart along x, which
* a 1.5 pc range walks end to end — and one 500 pc off that line, which nothing reaches. The dead
* end is real and the search proves it, with the last star it is allowed.
*
* A line rather than a crowd because the count has to be exact: a random cloud dense enough to
* connect leaves clumps the departure never reaches, and 39 662 of 40 000 settled is a budget that
* was never spent.
*/
const BUDGET_ISLAND = 99_999;
function budgetExactly(): StarNeighbourhood {
const line: StarPoint[] = Array.from({ length: 40_000 }, (_, i) => ({ id: i, x: i, y: 0, z: 0 }));
return new StarNeighbourhood([...line, { id: BUDGET_ISLAND, x: 0, y: 500, z: 0 }], 1.5);
}
/**
* 45 000 stars in a 10 pc cube — dense enough to stay one connected piece at half a parsec, where
* walking it costs more than a search's budget — with a chain a parsec a hop leaving its edge for
* 30 pc. Its cells are sized for the ranges asked of it, as the real catalogue's are for its own.
*/
const CROWD_CHAIN_END = 25;
const CROWD_ISLAND = 999999;
function crowdedKnot(): StarNeighbourhood {
let seed = 11;
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 10 - 5;
const knot: StarPoint[] = Array.from({ length: 45000 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
const chainOut: StarPoint[] = Array.from({ length: CROWD_CHAIN_END }, (_, i) => ({ id: i + 1, x: 5 + i + 1, y: 0, z: 0 }));
// One star nothing reaches, for the questions that have no answer.
return new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...knot, ...chainOut, { id: CROWD_ISLAND, x: 500, y: 0, z: 0 }], 0.25);
}
describe('minimumRangeBetween', () => {
it('works out the range past a dense knot around the departure', () => {
// Past the crowd the chain's hops of a parsec are the only way on, so a parsec is the
// answer, to the half-step the panel rounds up to.
expect(minimumRangeBetween(knotAndChain(), 0, CHAIN_END, 8).rangePc).toBeCloseTo(1, 1);
});
it('stops bisecting where a search gave up, and hands back a range that does work', { timeout: 30_000 }, () => {
// Below the chain's own hop of a parsec, the crowd is still one connected piece and larger than
// the budget, so those probes give up. Reading a give-up as "no chain at this range" is what
// used to report ranges up to 29% wider than needed, and went on paying for probes whose
// answers it could not use; the answer now is the narrowest range a chain was found at.
const knot = crowdedKnot();
const needed = minimumRangeBetween(knot, 0, CROWD_CHAIN_END, 1.2);
expect(needed.least).toBe(false);
expect(needed.rangePc).not.toBeNull();
expect(routeBetween(knot, 0, CROWD_CHAIN_END, needed.rangePc!).route).not.toBeNull();
// Narrower than the ceiling's own route, too: stopping before the bisection has found a range
// of its own hands back the ceiling, which is the control's maximum — the question, not an answer.
expect(needed.rangePc!).toBeLessThan(routeBetween(knot, 0, CROWD_CHAIN_END, 1.2).route!.longestHopPc);
});
it('names the shortest range that opens a way through', () => {
// Hops of 1 and 4: no range under 4 connects them, and 4 exactly does.
const stepped = index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 1, y: 0, z: 0 },
{ id: 2, x: 5, y: 0, z: 0 }
]);
expect(minimumRangeBetween(stepped, 0, 2, 50)).toEqual({ rangePc: expect.closeTo(4) as number, least: true });
expect(routeBetween(stepped, 0, 2, 4).route).not.toBeNull();
expect(routeBetween(stepped, 0, 2, 3.99).route).toBeNull();
});
it('prefers a longer way whose worst hop is shorter, since that is what the range pays for', () => {
// Direct: one hop of 10. Round: three hops of at most 4. The range only has to cover 4.
const both = index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 0, y: 4, z: 0 },
{ id: 2, x: 6, y: 7, z: 0 },
{ id: 3, x: 10, y: 0, z: 0 }
]);
const needed = minimumRangeBetween(both, 0, 3, 50);
expect(needed.rangePc).toBeLessThan(10);
expect(routeBetween(both, 0, 3, needed.rangePc!).route).not.toBeNull();
});
it('claims nothing about a ceiling its own search gave up on', () => {
// Nothing reaches the island at any range here, but the crowd spends the budget first, so the
// widest search proves nothing — and neither does the null it hands back.
const needed = minimumRangeBetween(crowdedKnot(), 0, CROWD_ISLAND, 0.5);
expect(needed).toEqual({ rangePc: null, least: false });
});
it('finds nothing when even the ceiling does not reach', () => {
const split = index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 100, y: 0, z: 0 }
]);
expect(minimumRangeBetween(split, 0, 1, 50)).toEqual({ rangePc: null, least: true });
});
});
/**
* The links a segment buffer draws, as unordered pairs of star ids, read back from where each end
* sits. Positions are compared as the float32 the buffer holds.
*/
function linksDrawn(segments: Float32Array, points: readonly StarPoint[]): string[] {
const idAt = new Map(points.map((point) => [[point.x, point.y, point.z].map(Math.fround).join(), point.id]));
const links: string[] = [];
for (let at = 0; at < segments.length; at += 6) {
const a = idAt.get(Array.from(segments.subarray(at, at + 3)).join())!;
const b = idAt.get(Array.from(segments.subarray(at + 3, at + 6)).join())!;
links.push(a < b ? `${a}-${b}` : `${b}-${a}`);
}
return links;
}
/**
* What a budget should keep, worked out the slow way: every link sorted by how near its nearer end
* is to the centre, then taken until one does not fit. Lengths and distances as the float32 buffer
* holds them.
*/
function nearestFirst(points: readonly StarPoint[], rangePc: number, centre: { x: number; y: number; z: number }, lengthPc: number): string[] {
const all = jumpLinkSegments(index([...points]), rangePc);
const links = Array.from({ length: all.length / 6 }, (_, link) => {
const v = Array.from(all.subarray(link * 6, link * 6 + 6));
const nearer = Math.fround(Math.sqrt(Math.min((v[0] - centre.x) ** 2 + (v[1] - centre.y) ** 2 + (v[2] - centre.z) ** 2, (v[3] - centre.x) ** 2 + (v[4] - centre.y) ** 2 + (v[5] - centre.z) ** 2)));
return { link, nearer, length: Math.fround(Math.hypot(v[3] - v[0], v[4] - v[1], v[5] - v[2])), key: linksDrawn(all.subarray(link * 6, link * 6 + 6), points)[0] };
}).sort((a, b) => a.nearer - b.nearer || a.link - b.link);
const kept: string[] = [];
let total = 0;
for (const { length, key } of links) {
if (total + length > lengthPc) {
break;
}
total += length;
kept.push(key);
}
return kept;
}
/** Stars a parsec apart along x, as points, for reading a segment buffer back. */
function chainPoints(count: number): StarPoint[] {
return Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 }));
}
describe('jumpLinkSegments', () => {
it('draws each pair once, not once from either end', () => {
const segments = jumpLinkSegments(chain(4), 1.5);
expect(linksDrawn(segments, chainPoints(4)).sort()).toEqual(['0-1', '1-2', '2-3']);
});
it('puts both ends of every link where its stars are', () => {
const segments = jumpLinkSegments(chain(3), 2.5);
expect(segments).toHaveLength(3 * 6);
expect(linksDrawn(segments, chainPoints(3)).sort()).toEqual(['0-1', '0-2', '1-2']);
});
it('draws nothing at no range', () => {
expect(jumpLinkSegments(chain(4), 0)).toHaveLength(0);
});
it('keeps the links nearest the centre first, for as much length as the budget holds', () => {
// A parsec apart from 0 to 20, the centre at 10.3. By nearer end: 9-10 and 10-11 (0.3 away),
// then 11-12 (0.7), then 8-9 (1.3). Three parsecs of them fit in 3.5; a fourth would not.
const budget = { centre: { x: 10.3, y: 0, z: 0 }, lengthPc: 3.5 };
const segments = jumpLinkSegments(chain(21), 1.5, budget);
expect(linksDrawn(segments, chainPoints(21)).sort()).toEqual(['10-11', '11-12', '9-10']);
expect(segments.buffer.byteLength).toBe(segments.byteLength);
});
it('keeps exactly the links a full nearest-first sort would, without sorting them all', () => {
let seed = 7;
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 40 - 20;
const points: StarPoint[] = Array.from({ length: 600 }, (_, id) => ({ id, x: random(), y: random(), z: random() }));
const centre = { x: 3, y: -2, z: 1 };
for (const lengthPc of [0, 5, 60, 900, 4000, 1e9]) {
expect(linksDrawn(jumpLinkSegments(index(points), 4, { centre, lengthPc }), points).sort()).toEqual(nearestFirst(points, 4, centre, lengthPc).sort());
}
});
it('sorts the distance band the budget runs out in, and stops at the first link there that does not fit', () => {
// One pair 4 kpc out makes each band about a parsec deep, so dozens of short links near the
// centre share the band the budget ends in, in whatever order the grid walks them.
let seed = 3;
const random = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
const points: StarPoint[] = [{ id: 0, x: 4000, y: 0, z: 0 }, { id: 1, x: 4000.03, y: 0, z: 0 }];
for (let pair = 0; pair < 40; pair++) {
const r = 0.05 + random() * 0.9;
const theta = random() * Math.PI * 2;
const x = r * Math.cos(theta);
const y = r * Math.sin(theta);
points.push({ id: 2 + pair * 2, x, y, z: 0 }, { id: 3 + pair * 2, x, y, z: 0.005 + random() * 0.04 });
}
const centre = { x: 0, y: 0, z: 0 };
for (const lengthPc of [0.1, 0.3, 0.5]) {
expect(linksDrawn(jumpLinkSegments(index(points), 0.05, { centre, lengthPc }), points).sort()).toEqual(nearestFirst(points, 0.05, centre, lengthPc).sort());
}
});
it('counts the budget in parsecs of link, not in links', () => {
// Stars at 0, 1 and 3: a 2 pc link nearest the centre, then a 1 pc one. Two and a half parsecs
// hold the first and not both, though two links would fit a count of two and a half.
const points: StarPoint[] = [{ id: 0, x: 0, y: 0, z: 0 }, { id: 1, x: 1, y: 0, z: 0 }, { id: 2, x: 3, y: 0, z: 0 }];
const segments = jumpLinkSegments(index(points), 2.5, { centre: { x: 3, y: 0, z: 0 }, lengthPc: 2.5 });
expect(linksDrawn(segments, points)).toEqual(['1-2']);
});
it('grows past its first buffer without losing a link', () => {
// 5 000 stars a tenth of a parsec apart, ten neighbours each way in range: some 50 000 links, far past
// the 4 096 the buffer starts with, so it has to grow several times.
const count = 5000;
const line = new StarNeighbourhood(Array.from({ length: count }, (_, i) => ({ id: i, x: i / 10, y: 0, z: 0 })));
// 1.05 rather than 1: the tenth neighbour sits at 1.0, which float steps of a tenth put either side of it.
const segments = jumpLinkSegments(line, 1.05);
let expected = 0;
for (let i = 0; i < count; i++) {
expected += Math.min(10, count - 1 - i);
}
expect(segments.length / 6).toBe(expected);
expect(segments.buffer.byteLength).toBe(segments.byteLength);
});
it('agrees with every route it makes possible', () => {
// The graph drawn and the graph walked have to be the same graph, or the map shows a way
// the route cannot take.
let seed = 11;
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 30 - 15;
const points: StarPoint[] = Array.from({ length: 120 }, (_, id) => ({ id, x: random(), y: random(), z: random() }));
const cloud = index(points);
// 9 rather than 6: at 6 this cloud falls into pieces and 0 never reaches 119, which an
// earlier version of this test hid by only checking the route it happened to find.
const range = 9;
const drawn = new Set(linksDrawn(jumpLinkSegments(cloud, range), points));
const { route } = routeBetween(cloud, 0, 119, range);
// Asserted, not guarded: a skipped body would let the two disagree unnoticed.
expect(route).not.toBeNull();
expect(route!.stars.length).toBeGreaterThan(2);
for (let i = 1; i < route!.stars.length; i++) {
const [a, b] = [route!.stars[i - 1], route!.stars[i]].sort((x, y) => x - y);
expect(drawn.has(`${a}-${b}`)).toBe(true);
}
expect(drawn.size).toBeGreaterThan(0);
});
});
+393
View File
@@ -0,0 +1,393 @@
/**
* Which stars are within reach of which, and how to get from one to another through them.
*
* A "jump link" is nothing more than a pair of catalogued stars closer together than some
* chosen range. It is not a feature of space — there are no corridors out there — it is a
* question asked of the catalogue: if a crossing of at most this far can be made, which stars
* can be strung together, and what is the shortest chain from here to there.
*
* Two facts about the catalogue shape everything here, and both are worth stating because the
* answers look like defects otherwise. It is magnitude-limited, so it is dense around the Sun
* and thins with distance: within 50 pc a 3 pc range links 99% of it into one piece, while over
* the whole 250 pc reach the same range leaves most stars alone. And a gap in it is a gap in
* what has been catalogued, not in what is there. So a route that cannot be found is a
* statement about the map, and `minimumRangeBetween` exists to say which.
*/
import { StarNeighbourhood } from './star-neighbourhood';
/** What a search found, and whether it looked everywhere the range reaches before answering. */
export interface RouteSearch {
readonly route: Route | null;
/** True when the search spent its budget: "no route" then means "gave up", not "there is none". */
readonly gaveUp: boolean;
}
/** A range that opens a route, and whether anything shorter was actually ruled out. */
export interface RangeSearch {
/** A range a chain was found at, or `null` where none was found up to the ceiling. */
readonly rangePc: number | null;
/** True when every shorter range was searched to exhaustion, so this is the least that works. */
readonly least: boolean;
}
/** A chain of stars from one to another, each hop within the range that was asked for. */
export interface Route {
/** Star ids, departure first and destination last. One hop is two ids. */
readonly stars: readonly number[];
/** The sum of the hops, in parsecs. */
readonly totalPc: number;
/**
* The longest single hop. The range has to cover this and nothing wider, so it is what a
* reader checks a route against — and it is the figure `minimumRangeBetween` minimises.
*/
readonly longestHopPc: number;
}
/**
* A cap on how much of the catalogue one search may walk, so a hopeless question cannot run for
* ever. It is a budget, not a verdict: a search that spends it has proved nothing, and says so
* through {@link RouteSearch.gaveUp}.
*
* Sized against the catalogue actually shipped rather than against the longest route. At 20 000 a
* search from the Sun to HD 120147 (136 pc) in jumps of 5 pc gave up, though the chain it wanted,
* 50 jumps, is there to be found; a star at 170 pc needed 58. Both are found at this budget. The
* cost is paid by questions with no answer, which walk the whole of it: from the Sun to the
* farthest star at 8 pc, 0.7 s at 20 000 against 2.1 s here, in the worker.
*/
const MAX_VISITED = 40000;
/**
* How close to the true minimum `minimumRangeBetween` works a range out: half the Routes panel's
* own step, which it rounds up to. Never at the cost of an answer that fails to open a route,
* since the figure it reports is always the longest hop of a route actually found.
*/
const RANGE_RESOLUTION_PC = 0.05;
/**
* How many of `minimumRangeBetween`'s probes may give up, once it has a range of its own, before it
* answers with what it has — and how many before it has one.
*
* A probe that finds a route is quick — it heads straight for the destination — while one that
* gives up walks the whole search budget, about two seconds on the real catalogue. Those are also
* the probes that buy the least: they cannot rule anything out. Two of them is the difference
* between an answer of 7.96 pc in half a second and 5.76 pc in seventeen, for a star at 236 pc; it
* lands on 5.97 pc in five.
*
* Until a probe succeeds there is nothing to answer with but the ceiling route's own longest hop,
* which is the control's maximum, so the bound is looser there — but a bound, since the search is
* one the panel waits on: five probes, ten seconds, rather than the resolution's own eight.
*/
const MAX_RANGE_GIVE_UPS = 2;
const MAX_UNEARNED_GIVE_UPS = 5;
/** A binary min-heap of star ids by priority. Duplicates are allowed; stale ones are skipped on the way out. */
class Frontier {
private readonly ids: number[] = [];
private readonly priorities: number[] = [];
get size(): number {
return this.ids.length;
}
push(id: number, priority: number): void {
let at = this.ids.length;
this.ids.push(id);
this.priorities.push(priority);
while (at > 0) {
const parent = (at - 1) >> 1;
if (this.priorities[parent] <= priority) {
break;
}
this.ids[at] = this.ids[parent];
this.priorities[at] = this.priorities[parent];
at = parent;
}
this.ids[at] = id;
this.priorities[at] = priority;
}
/** The id with the lowest priority, taken out. Only called while `size` is not zero. */
pop(): number {
const top = this.ids[0];
const lastId = this.ids.pop()!;
const lastPriority = this.priorities.pop()!;
const count = this.ids.length;
if (count > 0) {
let at = 0;
for (;;) {
const left = 2 * at + 1;
if (left >= count) {
break;
}
const right = left + 1;
const child = right < count && this.priorities[right] < this.priorities[left] ? right : left;
if (this.priorities[child] >= lastPriority) {
break;
}
this.ids[at] = this.ids[child];
this.priorities[at] = this.priorities[child];
at = child;
}
this.ids[at] = lastId;
this.priorities[at] = lastPriority;
}
return top;
}
}
function rebuild(cameFrom: Map<number, number>, fromId: number, toId: number): number[] {
const stars = [toId];
let at = toId;
while (at !== fromId) {
const previous = cameFrom.get(at);
if (previous === undefined) {
return [];
}
stars.push(previous);
at = previous;
}
return stars.reverse();
}
/**
* The shortest chain from one star to another in which no single hop exceeds `rangePc`, or no
* chain where the catalogue holds none within the search's budget.
*
* Shortest by total distance travelled rather than by number of hops: two chains of the same
* length are not equally good, and the one that covers less ground is the one a reader means by
* "the way there". Neighbours are asked for as the search reaches each star rather than built
* into a graph first, so finding one route never costs a pass over the whole catalogue.
*
* An A* search: each star waits its turn by the distance travelled to it plus the straight line
* on to the destination, which no chain can beat, so the search heads for the destination rather
* than widening evenly in every direction. Widening evenly is what the Gaia catalogue broke. From
* the Sun it spent its whole budget on the 20 000 stars nearest, all inside about 40 pc, and so
* found no route to anything farther at any range; Mirfak, 155 pc out, is 27 jumps at 8 pc.
*
* "No route" and "no chain" are not the same answer: a search that spends {@link MAX_VISITED}
* reports that it gave up, so nothing downstream reads it as proof that no chain exists.
*/
export function routeBetween(index: StarNeighbourhood, fromId: number, toId: number, rangePc: number): RouteSearch {
const origin = index.point(fromId);
const destination = index.point(toId);
if (fromId === toId || rangePc <= 0 || !origin || !destination) {
return { route: null, gaveUp: false };
}
const straightLineOn = (x: number, y: number, z: number) => Math.hypot(destination.x - x, destination.y - y, destination.z - z);
const travelled = new Map<number, number>([[fromId, 0]]);
const cameFrom = new Map<number, number>();
// Each hop's length as the range test measured it. The route's longest hop is read from these
// rather than measured again, so a range set to it is sure to admit the route a second time,
// which is what `minimumRangeBetween` relies on.
const hopTo = new Map<number, number>();
const settled = new Set<number>();
const frontier = new Frontier();
frontier.push(fromId, straightLineOn(origin.x, origin.y, origin.z));
let gaveUp = false;
while (frontier.size > 0) {
const starId = frontier.pop();
if (settled.has(starId)) {
continue;
}
// Counted against the budget only once the frontier has been drained of stale duplicates, so
// the flag below records why the search stopped rather than how full the set happened to be.
if (settled.size >= MAX_VISITED) {
gaveUp = true;
break;
}
settled.add(starId);
const costHere = travelled.get(starId)!;
if (starId === toId) {
const stars = rebuild(cameFrom, fromId, toId);
if (stars.length === 0) {
return { route: null, gaveUp: false };
}
let longestHopPc = 0;
for (let i = 1; i < stars.length; i++) {
longestHopPc = Math.max(longestHopPc, hopTo.get(stars[i])!);
}
return { route: { stars, totalPc: costHere, longestHopPc }, gaveUp: false };
}
index.forEachWithin(starId, rangePc, (neighbour, distancePc) => {
if (settled.has(neighbour.id)) {
return;
}
const cost = costHere + distancePc;
if (cost < (travelled.get(neighbour.id) ?? Number.POSITIVE_INFINITY)) {
travelled.set(neighbour.id, cost);
cameFrom.set(neighbour.id, starId);
hopTo.set(neighbour.id, distancePc);
frontier.push(neighbour.id, cost + straightLineOn(neighbour.x, neighbour.y, neighbour.z));
}
});
}
// An empty frontier means the range reaches nothing further; a spent budget means only that the
// search stopped looking.
return { route: null, gaveUp };
}
/**
* A range at which a chain exists between two stars — the shortest, to within
* `RANGE_RESOLUTION_PC`, where every shorter range could be ruled out — or `null` where no chain
* was found up to `ceilingPc`.
*
* This is what turns "no route" from a dead end into an answer: the range control can be told what
* it would have to be raised to. The figure aimed at is the minimax path, the chain whose longest
* hop is as short as possible. It used to be searched for directly, widening from the departure in
* order of the worst hop needed, which from the Sun meant exhausting the whole dense core before
* anything farther could be reached: it gave up with nothing after up to a minute. Whether a chain
* exists can only become truer as the range grows, so the range is bisected instead, each step one
* directed `routeBetween`.
*
* Each step has to answer "is there a chain at this range", and a search that gives up answers
* nothing. It is still worth carrying on from — the ranges above it are the ones left to try — but
* the result is no longer the least range, only a range that works, and `least` says which. The
* number of steps that may give up is bounded for the same reason: each one walks the whole budget,
* and 11 s of them for a star at 236 pc bought two decimal places nobody reads. Bounded more
* loosely before the bisection has found a range of its own, since until then the only range it
* could offer is the ceiling's, which is the control's maximum, for crossings that work well below
* it. See {@link MAX_RANGE_GIVE_UPS}.
*/
export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, toId: number, ceilingPc: number): RangeSearch {
const widest = routeBetween(index, fromId, toId, ceilingPc);
if (!widest.route) {
return { rangePc: null, least: !widest.gaveUp };
}
const ceilingHopPc = widest.route.longestHopPc;
let unreachable = 0;
let reachable = ceilingHopPc;
let giveUps = 0;
// While `reachable` is still the ceiling route's own longest hop the bisection has nothing of its
// own to answer with, and that figure sends the control to its maximum for a crossing that works
// well below — 8.00 pc for a star that routes at 6. So it is allowed more probes there, not
// unlimited ones: the panel is waiting on this.
while (reachable - unreachable > RANGE_RESOLUTION_PC && giveUps < (reachable === ceilingHopPc ? MAX_UNEARNED_GIVE_UPS : MAX_RANGE_GIVE_UPS)) {
const range = (unreachable + reachable) / 2;
const { route, gaveUp } = routeBetween(index, fromId, toId, range);
if (route) {
reachable = route.longestHopPc;
} else {
// A search that gave up is worth going on from — the ranges above it are the ones left to
// try — but it is not evidence that nothing routes here, so the answer stops being the least.
unreachable = range;
giveUps += gaveUp ? 1 : 0;
}
}
// Without a give-up the loop can only have ended by closing on the resolution, so that is the least.
return { rangePc: reachable, least: giveUps === 0 };
}
/** How much of a graph to keep: the links nearest a point, up to a total length. */
export interface LinkBudget {
/** Links are kept in order of how near their nearer end is to this point. */
readonly centre: { readonly x: number; readonly y: number; readonly z: number };
/** The most the kept links may add up to, end to end, in parsecs. */
readonly lengthPc: number;
}
/**
* How many distance bands a budgeted graph is split into to find where its budget runs out, so that
* only the links in that one band are sorted rather than all of them.
*/
const DISTANCE_BANDS = 4096;
/**
* Every link within `rangePc` between two of the stars `index` holds, each pair once, as vertex
* pairs ready to draw: six floats a link, one end then the other. With a `budget`, only the links
* nearest its centre, as many as fit its length.
*
* For drawing the graph, which is the only thing that wants all of it: routing asks for a star's
* neighbours as it reaches that star and never builds this. Written straight into floats rather
* than collected as link objects first, since at 8 pc the drawn stars alone have hundreds of
* thousands of links, and the whole catalogue 3.7 million.
*/
export function jumpLinkSegments(index: StarNeighbourhood, rangePc: number, budget?: LinkBudget): Float32Array {
let vertices = new Float32Array(6 * 4096);
let length = 0;
index.forEachPairWithin(rangePc, (a, b) => {
if (length + 6 > vertices.length) {
const grown = new Float32Array(vertices.length * 2);
grown.set(vertices);
vertices = grown;
}
vertices[length++] = a.x;
vertices[length++] = a.y;
vertices[length++] = a.z;
vertices[length++] = b.x;
vertices[length++] = b.y;
vertices[length++] = b.z;
});
if (!budget) {
// Exact length rather than a view on the grown buffer: the answer is transferred whole, and a
// view would carry up to as much again in unused capacity with it.
return vertices.slice(0, length);
}
// Each link's nearer end's distance from the centre, and its length.
const { centre } = budget;
const count = length / 6;
const nearness = new Float32Array(count);
const lengths = new Float32Array(count);
let totalPc = 0;
let farthest = 0;
for (let link = 0; link < count; link++) {
const at = link * 6;
const ax = vertices[at] - centre.x;
const ay = vertices[at + 1] - centre.y;
const az = vertices[at + 2] - centre.z;
const bx = vertices[at + 3] - centre.x;
const by = vertices[at + 4] - centre.y;
const bz = vertices[at + 5] - centre.z;
nearness[link] = Math.sqrt(Math.min(ax * ax + ay * ay + az * az, bx * bx + by * by + bz * bz));
lengths[link] = Math.hypot(bx - ax, by - ay, bz - az);
totalPc += lengths[link];
farthest = Math.max(farthest, nearness[link]);
}
if (totalPc <= budget.lengthPc) {
return vertices.slice(0, length);
}
// Nearest first, without sorting them all: every link in the bands before the one where the budget
// runs out fits, and only that band's links are sorted to see how many of them do. Sorting all
// 730 000 links at 30 pc from the Sun to keep 4 400 doubled the time a graph took in the worker.
const bands = new Uint16Array(count);
const bandLengths = new Float64Array(DISTANCE_BANDS);
const bandsPerPc = farthest > 0 ? DISTANCE_BANDS / farthest : 0;
for (let link = 0; link < count; link++) {
bands[link] = Math.min(DISTANCE_BANDS - 1, Math.floor(nearness[link] * bandsPerPc));
bandLengths[bands[link]] += lengths[link];
}
let lastBand = 0;
let keptPc = 0;
while (keptPc + bandLengths[lastBand] <= budget.lengthPc) {
keptPc += bandLengths[lastBand++];
}
const keptLinks: number[] = [];
const boundary: number[] = [];
for (let link = 0; link < count; link++) {
const band = bands[link];
if (band < lastBand) {
keptLinks.push(link);
} else if (band === lastBand) {
boundary.push(link);
}
}
boundary.sort((a, b) => nearness[a] - nearness[b] || a - b);
for (const link of boundary) {
if (keptPc + lengths[link] > budget.lengthPc) {
break;
}
keptPc += lengths[link];
keptLinks.push(link);
}
const kept = new Float32Array(keptLinks.length * 6);
keptLinks.forEach((link, at) => kept.set(vertices.subarray(link * 6, link * 6 + 6), at * 6));
return kept;
}
+42
View File
@@ -4,9 +4,11 @@ import { GM_SUN_AU3_PER_DAY2, DEFAULT_EPOCH_JD } from './constants';
import { import {
gravitationalParameterFromPeriod, gravitationalParameterFromPeriod,
isPropagatableOrbit, isPropagatableOrbit,
meanElementsAt,
meanMotionRadPerDay, meanMotionRadPerDay,
orbitEllipsePoints, orbitEllipsePoints,
orbitalPeriodDays, orbitalPeriodDays,
positionAtEpoch,
positionAtTrueAnomaly, positionAtTrueAnomaly,
propagateOrbit, propagateOrbit,
resolveGravitationalParameter, resolveGravitationalParameter,
@@ -131,6 +133,46 @@ describe('propagateOrbit', () => {
}); });
}); });
describe('meanElementsAt', () => {
/** A circle in the reference plane, prograde (0) or retrograde (180), whose node turns. */
function circle(inclinationDeg: number) {
return { semiMajorAxisAu: 1, eccentricity: 0, inclinationDeg, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD };
}
const RATES = { meanMotionDegPerDay: 10, longitudeOfAscendingNodeDegPerDay: 0.5, argumentOfPeriapsisDegPerDay: 0.2 };
/** Longitude in the reference plane a day on, in degrees, signed. */
function longitudeAfterOneDay(inclinationDeg: number): number {
const { x, y } = positionAtEpoch(meanElementsAt(circle(inclinationDeg), RATES, DEFAULT_EPOCH_JD + 1));
return (Math.atan2(y, x) * 180) / Math.PI;
}
it('goes round at its mean motion however its node and periapsis turn', () => {
expect(longitudeAfterOneDay(0)).toBeCloseTo(10, 9);
});
it('goes round a retrograde orbit backwards at the same rate, the node’s turning added back', () => {
// Taking the node off as for a prograde orbit made this 9 degrees, and Triton drifted a
// degree a year from where Horizons has it.
expect(longitudeAfterOneDay(180)).toBeCloseTo(-10, 9);
});
it('turns the node and periapsis at their own rates, and dates the result', () => {
const later = meanElementsAt(circle(0), RATES, DEFAULT_EPOCH_JD + 4);
expect(later.longitudeOfAscendingNodeDeg).toBeCloseTo(2, 12);
expect(later.argumentOfPeriapsisDeg).toBeCloseTo(0.8, 12);
expect(later.epochJd).toBe(DEFAULT_EPOCH_JD + 4);
});
it('adds Standish’s b T² + c cos(fT) + s sin(fT) to the mean anomaly', () => {
const terms = { b: -0.00012452, c: 0.0606406, s: -0.35635438, f: 38.35125 };
const T = 0.7;
const withTerms = meanElementsAt(circle(0), { ...RATES, meanAnomalyTerms: terms }, DEFAULT_EPOCH_JD + T * 36525);
const without = meanElementsAt(circle(0), RATES, DEFAULT_EPOCH_JD + T * 36525);
const f = (terms.f * T * Math.PI) / 180;
expect(withTerms.meanAnomalyAtEpochDeg - without.meanAnomalyAtEpochDeg).toBeCloseTo(terms.b * T * T + terms.c * Math.cos(f) + terms.s * Math.sin(f), 9);
});
});
describe('orbitEllipsePoints', () => { describe('orbitEllipsePoints', () => {
it('samples a closed loop whose distances stay within the periapsis/apoapsis bounds', () => { it('samples a closed loop whose distances stay within the periapsis/apoapsis bounds', () => {
const elements = resolveOrbitalElements({ semiMajorAxisAu: 5, eccentricity: 0.4 }); const elements = resolveOrbitalElements({ semiMajorAxisAu: 5, eccentricity: 0.4 });
+56 -9
View File
@@ -1,9 +1,10 @@
import { CartesianCoordinates } from './coordinates'; import { CartesianCoordinates } from './coordinates';
import { DEFAULT_EPOCH_JD, GM_SUN_AU3_PER_DAY2 } from './constants'; import { DEFAULT_EPOCH_JD, GM_SUN_AU3_PER_DAY2 } from './constants';
import { OrbitalElements } from '../models/body.model'; import { MeanElementRates, OrbitalElements } from '../models/body.model';
const DEG_TO_RAD = Math.PI / 180; const DEG_TO_RAD = Math.PI / 180;
const TWO_PI = Math.PI * 2; const TWO_PI = Math.PI * 2;
const DAYS_PER_JULIAN_CENTURY = 36525;
/** /**
* Fills in the elements the Kepler propagator needs but that some sources (e.g. exoplanets, * Fills in the elements the Kepler propagator needs but that some sources (e.g. exoplanets,
@@ -208,17 +209,63 @@ export function positionAtTrueAnomaly(elements: OrbitalElements, trueAnomalyRad:
} }
/** /**
* Propagates `elements` to Julian date `epochJdEval`, returning the body's position (AU) * The rates of an orbit that only goes round: Kepler's mean motion from the central mass, with
* relative to its central body. This is the app's "current epoch" evaluation used for live * nothing turning. What an exoplanet has, since the archive publishes no precession.
* (and future time-scrubbable) positions, as opposed to {@link orbitEllipsePoints} which */
export function keplerRates(semiMajorAxisAu: number, gmAu3PerDay2: number): MeanElementRates {
return {
meanMotionDegPerDay: meanMotionRadPerDay(semiMajorAxisAu, gmAu3PerDay2) / DEG_TO_RAD,
longitudeOfAscendingNodeDegPerDay: 0,
argumentOfPeriapsisDegPerDay: 0
};
}
/**
* The elements at `epochJdEval`, each moved from its epoch at its own rate, and returned with that
* date as their epoch — so {@link positionAtEpoch} places the body, and the node and periapsis
* say where to draw the orbit it is on.
*
* The mean anomaly is what is left of the body's motion once the node and periapsis have turned:
* `meanMotionDegPerDay` is how fast it goes round in space, and a periapsis that has moved on is
* that much further to reach. On a retrograde orbit, past 90 degrees, the body runs against the
* direction the node is counted in, so the node's turning is added back rather than taken off.
* Taken off, Triton — whose node turns half a degree a year — drifted a degree a year from where
* Horizons has it, 105 degrees by 2100.
*/
export function meanElementsAt(elements: OrbitalElements, rates: MeanElementRates, epochJdEval: number): OrbitalElements {
const days = epochJdEval - elements.epochJd;
const node = rates.longitudeOfAscendingNodeDegPerDay * days;
const periapsis = rates.argumentOfPeriapsisDegPerDay * days;
const nodeAlongOrbit = elements.inclinationDeg > 90 ? -node : node;
const terms = rates.meanAnomalyTerms;
const centuries = days / DAYS_PER_JULIAN_CENTURY;
const extra = terms
? terms.b * centuries * centuries + terms.c * Math.cos(terms.f * centuries * DEG_TO_RAD) + terms.s * Math.sin(terms.f * centuries * DEG_TO_RAD)
: 0;
return {
semiMajorAxisAu: elements.semiMajorAxisAu + (rates.semiMajorAxisAuPerDay ?? 0) * days,
eccentricity: elements.eccentricity + (rates.eccentricityPerDay ?? 0) * days,
inclinationDeg: elements.inclinationDeg + (rates.inclinationDegPerDay ?? 0) * days,
longitudeOfAscendingNodeDeg: elements.longitudeOfAscendingNodeDeg + node,
argumentOfPeriapsisDeg: elements.argumentOfPeriapsisDeg + periapsis,
meanAnomalyAtEpochDeg: elements.meanAnomalyAtEpochDeg + rates.meanMotionDegPerDay * days - periapsis - nodeAlongOrbit + extra,
epochJd: epochJdEval
};
}
/** Where `elements` put the body at their own epoch (AU, relative to the central body). */
export function positionAtEpoch(elements: OrbitalElements): CartesianCoordinates {
const eccentricAnomalyRad = solveEccentricAnomaly(elements.meanAnomalyAtEpochDeg * DEG_TO_RAD, elements.eccentricity);
return positionAtTrueAnomaly(elements, trueAnomalyFromEccentricAnomaly(eccentricAnomalyRad, elements.eccentricity));
}
/**
* Propagates `elements` to Julian date `epochJdEval` around a central mass, returning the body's
* position (AU) relative to its central body, as opposed to {@link orbitEllipsePoints} which
* samples the fixed orbit shape independent of time. * samples the fixed orbit shape independent of time.
*/ */
export function propagateOrbit(elements: OrbitalElements, gmAu3PerDay2: number, epochJdEval: number): CartesianCoordinates { export function propagateOrbit(elements: OrbitalElements, gmAu3PerDay2: number, epochJdEval: number): CartesianCoordinates {
const meanMotion = meanMotionRadPerDay(elements.semiMajorAxisAu, gmAu3PerDay2); return positionAtEpoch(meanElementsAt(elements, keplerRates(elements.semiMajorAxisAu, gmAu3PerDay2), epochJdEval));
const meanAnomalyRad = elements.meanAnomalyAtEpochDeg * DEG_TO_RAD + meanMotion * (epochJdEval - elements.epochJd);
const eccentricAnomalyRad = solveEccentricAnomaly(meanAnomalyRad, elements.eccentricity);
const trueAnomalyRad = trueAnomalyFromEccentricAnomaly(eccentricAnomalyRad, elements.eccentricity);
return positionAtTrueAnomaly(elements, trueAnomalyRad);
} }
/** /**
+193
View File
@@ -0,0 +1,193 @@
import { describe, expect, it } from 'vitest';
import { parsePlanetMeanElements, parseSatelliteMeanElements, parseSmallBodyElements, SbdbAnswer } from './mean-elements';
/** Standish's p_elem_t2.txt, cut to the lines that matter here, as JPL published them. */
const TABLE_2 = `Keplerian elements and their rates, with respect to the mean ecliptic and equinox of J2000,
valid for the time-interval 3000 BC -- 3000 AD. NOTE: the computation of M for Jupiter through
Pluto *must* be augmented by the additional terms given in Table 2b (below).
EM Bary 1.00000018 0.01673163 -0.00054346 100.46691572 102.93005885 -5.11260389
-0.00000003 -0.00003661 -0.01337178 35999.37306329 0.31795260 -0.24123856
Jupiter 5.20248019 0.04853590 1.29861416 34.33479152 14.27495244 100.29282654
-0.00002864 0.00018026 -0.00322699 3034.90371757 0.18199196 0.13024619
Pluto 39.48686035 0.24885238 17.14104260 238.96535011 224.09702598 110.30167986
0.00449751 0.00006016 0.00000501 145.18042903 -0.00968827 -0.00809981
Table 2b.
Jupiter -0.00012452 0.06064060 -0.35635438 38.35125000
Pluto -0.01262724
`;
/** The satellite page's markup around three rows, as the 2021 page served it. */
const SATELLITES = `
<td align="left" nowrap><b>Satellites of Earth</b></td>
<td align="right" nowrap><b>jump to:</b> <a href="#earth">Earth</a>, <a href="#mars">Mars</a></td>
<H3>Mean <a href="?glossary&term=ecliptic">ecliptic</a> orbital elements</H3>
Epoch 2000 Jan. 1.50 TT<BR>
<TR ALIGN=right><TD ALIGN=left>Moon</TD>
<TD>384400.</TD><TD>0.0554</TD><TD>318.15</TD><TD>135.27</TD><TD>5.16</TD><TD>125.08</TD>
<TD>13.176358</TD><TD>27.322</TD><TD>5.997</TD><TD>18.600</TD>
<TD ALIGN=right><A HREF="#ref1">1</A></TD></TR>
<td align="left" nowrap><b>Satellites of Jupiter</b></td>
<td align="right" nowrap><b>jump to:</b> <a href="#earth">Earth</a>, <a href="#mars">Mars</a></td>
<H3>Mean orbital elements referred to the local <a href="?glossary&term=lp">Laplace planes</a></H3>
Epoch 1997 Jan. 16.00 TT<BR>
<TR ALIGN=right><TD ALIGN=left>Io</TD><TD>421800.</TD><TD>0.0041</TD>
<TD>84.129</TD><TD>342.021</TD><TD>0.036</TD><TD>43.977</TD><TD>203.4889583</TD>
<TD>1.769</TD><TD>1.625</TD><TD>7.420</TD><TD>268.057</TD><TD>64.495</TD>
<TD>0.000</TD>
<TD ALIGN=right><A HREF="#ref11">11</A></TD></TR>
<td align="left" nowrap><b>Satellites of Neptune</b></td>
<td align="right" nowrap><b>jump to:</b> <a href="#earth">Earth</a>, <a href="#mars">Mars</a></td>
<H3>Mean orbital elements referred to the local <a href="?glossary&term=lp">Laplace planes</a></H3>
Epoch 2000 Jan. 1.50 TT<BR>
<TR ALIGN=right><TD ALIGN=left>Triton</TD><TD>354759.</TD><TD>0.0000</TD>
<TD>66.142</TD><TD>352.257</TD><TD>156.865</TD><TD>177.608</TD>
<TD>61.2572638</TD><TD>5.877</TD><TD>386.371</TD><TD>687.446</TD>
<TD>299.456</TD><TD>43.414</TD><TD>0.010</TD>
<TD ALIGN=right><A HREF="#ref54">54</A></TD></TR>
<td align="left" nowrap><b>Satellites of Uranus</b></td>
<td align="right" nowrap><b>jump to:</b> <a href="#earth">Earth</a>, <a href="#mars">Mars</a></td>
<H3>Mean equatorial orbital elements</H3>
Epoch 1980 Jan. 1.0 TT<BR>
<TR ALIGN=right><TD ALIGN=left>Titania</TD><TD>436300.</TD><TD>0.0011</TD>
<TD>284.400</TD><TD>24.614</TD><TD>0.079</TD><TD>99.771</TD><TD>41.3514246</TD>
<TD>8.706</TD><TD>161.525</TD><TD>195.369</TD>
<TD ALIGN=right><A HREF="#ref10">10</A></TD></TR>
`;
/** Ceres as the SBDB API answers `sstr=Ceres&phys-par=1&full-prec=1`, cut to what is read. */
const CERES: SbdbAnswer = {
orbit: {
epoch: '2461200.5',
elements: [
{ name: 'e', value: '.07969229514816586' },
{ name: 'a', value: '2.765552595034094' },
{ name: 'q', value: '2.545159361382861' },
{ name: 'i', value: '10.58802780183462' },
{ name: 'om', value: '80.24862682043221' },
{ name: 'w', value: '73.29421453021587' },
{ name: 'ma', value: '274.4193463761342' },
{ name: 'tp', value: '2461599.841466614066' },
{ name: 'per', value: '1679.853119758983' },
{ name: 'n', value: '.21430445064843' },
{ name: 'ad', value: '2.985945828685327' }
]
},
phys_par: [
{ name: 'H', value: '3.34' },
{ name: 'diameter', value: '939.4' },
{ name: 'GM', value: '62.6284' },
{ name: 'rot_per', value: '9.074170' }
]
};
describe('parsePlanetMeanElements', () => {
it('turns Standish’s longitudes into the argument of periapsis and mean anomaly', () => {
const { orbit } = parsePlanetMeanElements(TABLE_2, 'jupiter');
expect(orbit.argumentOfPeriapsisDeg).toBeCloseTo(14.27495244 - 100.29282654, 8);
expect(orbit.meanAnomalyAtEpochDeg).toBeCloseTo(34.33479152 - 14.27495244, 8);
expect(orbit.epochJd).toBe(2451545);
});
it('gives the rates per day, the mean motion being the mean longitude’s', () => {
const { rates } = parsePlanetMeanElements(TABLE_2, 'earth');
// 35 999.373 degrees a century is the sidereal year.
expect(360 / rates.meanMotionDegPerDay).toBeCloseTo(365.2564, 4);
expect(rates.argumentOfPeriapsisDegPerDay * 36525).toBeCloseTo(0.3179526 + 0.24123856, 8);
// And every other rate the row gives, a century's worth: dropped, Saturn moved 0.66 degrees by
// AD 1 without its node's and 0.36 by AD 3000 without its a, e and i, where no date from 1950 to
// 2100 shows more than 0.036.
expect(rates.longitudeOfAscendingNodeDegPerDay * 36525).toBeCloseTo(-0.24123856, 8);
expect(rates.semiMajorAxisAuPerDay! * 36525).toBeCloseTo(-0.00000003, 8);
expect(rates.eccentricityPerDay! * 36525).toBeCloseTo(-0.00003661, 8);
expect(rates.inclinationDegPerDay! * 36525).toBeCloseTo(-0.01337178, 8);
});
it('carries Table 2b’s terms for Jupiter and beyond, and none for the inner planets', () => {
expect(parsePlanetMeanElements(TABLE_2, 'jupiter').rates.meanAnomalyTerms).toEqual({ b: -0.00012452, c: 0.0606406, s: -0.35635438, f: 38.35125 });
expect(parsePlanetMeanElements(TABLE_2, 'earth').rates.meanAnomalyTerms).toBeUndefined();
});
it('reads Pluto’s row, not the note above the table that starts a line with its name', () => {
const pluto = parsePlanetMeanElements(TABLE_2, 'pluto');
expect(pluto.orbit.semiMajorAxisAu).toBe(39.48686035);
expect(pluto.rates.meanAnomalyTerms).toEqual({ b: -0.01262724, c: 0, s: 0, f: 0 });
});
});
describe('parseSatelliteMeanElements', () => {
it('reads a Laplace-plane row with its pole and its section’s epoch', () => {
const io = parseSatelliteMeanElements(SATELLITES, 'Jupiter', 'Io', true);
expect(io.laplacePole).toEqual({ raDeg: 268.057, decDeg: 64.495 });
expect(io.orbit.epochJd).toBe(2450464.5);
expect(io.rates.meanMotionDegPerDay).toBe(203.4889583);
expect(io.orbitSource).toBe('JPL SSD satellite mean elements, epoch 1997 Jan 16');
});
it('reads the Moon against the ecliptic, with no pole', () => {
const moon = parseSatelliteMeanElements(SATELLITES, 'Earth', 'Moon', false);
expect(moon.laplacePole).toBeUndefined();
expect(moon.orbit.epochJd).toBe(2451545);
expect(moon.orbit.semiMajorAxisAu * 149597870.7).toBeCloseTo(384400, 3);
});
it('regresses a prograde node and advances a periapsis, as the planet’s oblateness turns them', () => {
const { rates } = parseSatelliteMeanElements(SATELLITES, 'Earth', 'Moon', false);
expect(rates.longitudeOfAscendingNodeDegPerDay).toBeCloseTo(-360 / (18.6 * 365.25), 9);
expect(rates.argumentOfPeriapsisDegPerDay).toBeCloseTo(360 / (5.997 * 365.25), 9);
});
it('advances the node of a retrograde orbit', () => {
const { rates } = parseSatelliteMeanElements(SATELLITES, 'Neptune', 'Triton', false);
expect(rates.longitudeOfAscendingNodeDegPerDay).toBeCloseTo(360 / (687.446 * 365.25), 9);
});
it('reads a section referred to the planet’s equator against the pole it is given', () => {
const pole = { raDeg: 77.311, decDeg: 15.175 };
const titania = parseSatelliteMeanElements(SATELLITES, 'Uranus', 'Titania', false, pole);
expect(titania.laplacePole).toEqual(pole);
expect(titania.orbit.epochJd).toBe(2444239.5);
expect(titania.rates.meanMotionDegPerDay).toBe(41.3514246);
// Read as ecliptic elements, which is what a missing pole would mean, Titania is 88 degrees
// from Horizons on 2025-01-01.
expect(() => parseSatelliteMeanElements(SATELLITES, 'Uranus', 'Titania', false)).toThrow(/equator/);
expect(() => parseSatelliteMeanElements(SATELLITES, 'Jupiter', 'Io', true, pole)).toThrow(/equator/);
});
it('turns the periapsis backwards where a resonance holds it', () => {
const { rates } = parseSatelliteMeanElements(SATELLITES, 'Jupiter', 'Io', true);
expect(rates.argumentOfPeriapsisDegPerDay).toBeCloseTo(-360 / (1.625 * 365.25), 9);
});
});
describe('parseSmallBodyElements', () => {
it('carries a dwarf planet on its osculating elements at their own mean motion', () => {
const ceres = parseSmallBodyElements(CERES);
expect(ceres.orbit).toEqual({
semiMajorAxisAu: 2.765552595034094,
eccentricity: 0.07969229514816586,
inclinationDeg: 10.58802780183462,
longitudeOfAscendingNodeDeg: 80.24862682043221,
argumentOfPeriapsisDeg: 73.29421453021587,
meanAnomalyAtEpochDeg: 274.4193463761342,
epochJd: 2461200.5
});
expect(ceres.rates).toEqual({ meanMotionDegPerDay: 0.21430445064843, longitudeOfAscendingNodeDegPerDay: 0, argumentOfPeriapsisDegPerDay: 0 });
expect(ceres.laplacePole).toBeUndefined();
expect(ceres.orbitSource).toBe('JPL SBDB osculating elements, epoch 2026 Jun 9');
});
it('takes half the diameter as the radius, and the rotation period in hours', () => {
const ceres = parseSmallBodyElements(CERES);
expect(ceres.radiusKm).toBe(469.7);
expect(ceres.rotationPeriodHours).toBe(9.07417);
});
it('leaves out what the answer does not publish', () => {
const eris = parseSmallBodyElements({ ...CERES, phys_par: [{ name: 'rot_per', value: '25.9' }] });
expect(eris.radiusKm).toBeUndefined();
expect(eris.rotationPeriodHours).toBe(25.9);
});
});
+223
View File
@@ -0,0 +1,223 @@
import { MeanElementRates, OrbitalElements } from '../models/body.model';
/**
* Reads JPL's two tables of mean orbital elements, which the ETL fetches (see
* `tools/etl/lib/mean-elements.ts`), into the elements and rates `bodies.json` carries.
*/
const KM_PER_AU = 149597870.7;
const J2000_JD = 2451545.0;
const DAYS_PER_JULIAN_CENTURY = 36525;
const DAYS_PER_JULIAN_YEAR = 365.25;
const PLANET_ORBIT_SOURCE = 'JPL approximate mean elements (Standish), fit for 3000 BC to AD 3000';
export interface MeanOrbit {
orbit: OrbitalElements;
rates: MeanElementRates;
laplacePole?: { raDeg: number; decDeg: number };
orbitSource: string;
}
/** Table 2a's name for each planet; Earth's row is the Earth-Moon barycentre, 4 700 km off Earth. */
const PLANET_ROW_NAMES: Record<string, string> = {
mercury: 'Mercury',
venus: 'Venus',
earth: 'EM Bary',
mars: 'Mars',
jupiter: 'Jupiter',
saturn: 'Saturn',
uranus: 'Uranus',
neptune: 'Neptune',
pluto: 'Pluto'
};
function numbers(text: string): number[] {
return text.trim().split(/\s+/).map(Number);
}
/**
* Reads one planet's row pair from Table 2a, and its Table 2b terms where it has them. The
* elements are Standish's own — a, e, I, mean longitude L, longitude of perihelion ϖ, node Ω —
* turned into the argument of periapsis ϖ - Ω and mean anomaly L - ϖ the propagator takes.
*/
export function parsePlanetMeanElements(text: string, bodyId: string): MeanOrbit {
const name = PLANET_ROW_NAMES[bodyId];
const lines = text.split(/\r?\n/);
// A row is the name followed by a number: the notes above the table start a line with "Pluto" too.
const rows = lines.flatMap((line, index) => (name && new RegExp(`^${name}\\s+-?[\\d.]`).test(line) ? [index] : []));
if (rows.length === 0) {
throw new Error(`No row for ${bodyId} in Standish's Table 2a.`);
}
const values = [...numbers(lines[rows[0]].slice(name.length)), ...numbers(lines[rows[0] + 1])];
if (values.length !== 12 || !values.every(Number.isFinite)) {
throw new Error(`Standish's Table 2a rows for ${name} did not parse: ${values.join(' ')}`);
}
const [a, e, inclination, meanLongitude, perihelion, node, aRate, eRate, inclinationRate, meanLongitudeRate, perihelionRate, nodeRate] = values;
// Table 2b repeats the name further down, with b, c, s, f (Pluto has b alone).
const extra = rows[1] === undefined ? undefined : numbers(lines[rows[1]].slice(name.length));
const [b = 0, c = 0, s = 0, f = 0] = extra ?? [];
return {
orbit: {
semiMajorAxisAu: a,
eccentricity: e,
inclinationDeg: inclination,
longitudeOfAscendingNodeDeg: node,
argumentOfPeriapsisDeg: perihelion - node,
meanAnomalyAtEpochDeg: meanLongitude - perihelion,
epochJd: J2000_JD
},
rates: {
meanMotionDegPerDay: meanLongitudeRate / DAYS_PER_JULIAN_CENTURY,
longitudeOfAscendingNodeDegPerDay: nodeRate / DAYS_PER_JULIAN_CENTURY,
argumentOfPeriapsisDegPerDay: (perihelionRate - nodeRate) / DAYS_PER_JULIAN_CENTURY,
semiMajorAxisAuPerDay: aRate / DAYS_PER_JULIAN_CENTURY,
eccentricityPerDay: eRate / DAYS_PER_JULIAN_CENTURY,
inclinationDegPerDay: inclinationRate / DAYS_PER_JULIAN_CENTURY,
...(extra ? { meanAnomalyTerms: { b, c, s, f } } : {})
},
orbitSource: PLANET_ORBIT_SOURCE
};
}
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
/** `1997 Jan. 16.00` as a Julian date. TT and TDB differ by under two milliseconds. */
function julianDate(year: number, month: string, day: number): number {
const monthIndex = MONTHS.indexOf(month);
if (monthIndex < 0) {
throw new Error(`Unknown month ${month}.`);
}
return Date.UTC(year, monthIndex, 1) / 86400000 + 2440587.5 + day - 1;
}
/**
* Reads one moon's row from the satellite page: `a e w M i node n P Pw Pnode`, then the Laplace
* pole `RA Dec Tilt` where the section is referred to one, then a reference number.
*
* The page gives the two precession periods as magnitudes, so their sense is supplied here. A
* node driven by the planet's oblateness regresses on a prograde orbit and advances on a
* retrograde one, and the orbit's inclination says which. A periapsis advances — except where a
* resonance forces the eccentricity, which `apsidesRegress` names: Io's and Europa's are held to
* the line of their conjunctions, which turns backwards at 2 n(Europa) - n(Io) = 0.74 degrees a
* day, and that is exactly the 1.625- and 1.394-year periods the table gives for them. Read as
* advancing, Io was 0.9 degrees out and Europa 2.1.
*
* Uranus's and Pluto's sections are referred to the planet's equator instead, and the page does
* not print its pole, so the caller passes it as `equatorPole`: the elements are then read
* against that pole exactly as against a Laplace plane's.
*/
export function parseSatelliteMeanElements(
html: string,
planetName: string,
moonName: string,
apsidesRegress: boolean,
equatorPole?: { raDeg: number; decDeg: number }
): MeanOrbit {
const text = html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ');
const section = text.indexOf(`Satellites of ${planetName} jump to`);
if (section < 0) {
throw new Error(`No section for the satellites of ${planetName}.`);
}
const row = text.slice(section).match(new RegExp(` ${moonName} ((?:-?[\\d.]+ )+)`));
if (!row || row.index === undefined) {
throw new Error(`No row for ${moonName} among the satellites of ${planetName}.`);
}
const before = text.slice(section, section + row.index);
const epoch = [...before.matchAll(/Epoch (\d{4}) (\w{3})\. ([\d.]+) T/g)].at(-1);
if (!epoch) {
throw new Error(`No epoch above ${moonName}'s row.`);
}
// The nearest heading above the row says which plane its section is referred to; the ecliptic
// where there is none.
const [plane] = ['Mean ecliptic', 'Laplace plane', 'Mean equatorial'].sort((x, y) => before.lastIndexOf(y) - before.lastIndexOf(x));
const laplace = plane === 'Laplace plane';
const equatorial = plane === 'Mean equatorial';
if (equatorial !== (equatorPole !== undefined)) {
throw new Error(`${moonName}'s elements are ${equatorial ? '' : 'not '}referred to ${planetName}'s equator, and its pole was ${equatorPole ? '' : 'not '}given.`);
}
const values = numbers(row[1]);
const expected = laplace ? 14 : 11;
if (values.length !== expected || !values.every(Number.isFinite)) {
throw new Error(`${moonName}'s row has ${values.length} numbers, ${expected} expected: ${row[1]}`);
}
const [aKm, e, periapsis, meanAnomaly, inclination, node, meanMotion, , periapsisPeriodYears, nodePeriodYears, raDeg, decDeg] = values;
const nodeSense = inclination > 90 ? 1 : -1;
const periapsisSense = apsidesRegress ? -1 : 1;
const perDay = (periodYears: number): number => (periodYears > 0 ? 360 / (periodYears * DAYS_PER_JULIAN_YEAR) : 0);
return {
orbit: {
semiMajorAxisAu: aKm / KM_PER_AU,
eccentricity: e,
inclinationDeg: inclination,
longitudeOfAscendingNodeDeg: node,
argumentOfPeriapsisDeg: periapsis,
meanAnomalyAtEpochDeg: meanAnomaly,
epochJd: julianDate(Number(epoch[1]), epoch[2], Number(epoch[3]))
},
rates: {
meanMotionDegPerDay: meanMotion,
longitudeOfAscendingNodeDegPerDay: nodeSense * perDay(nodePeriodYears),
argumentOfPeriapsisDegPerDay: periapsisSense * perDay(periapsisPeriodYears)
},
...(laplace ? { laplacePole: { raDeg, decDeg } } : equatorPole ? { laplacePole: equatorPole } : {}),
orbitSource: `JPL SSD satellite mean elements, epoch ${epoch[1]} ${epoch[2]} ${Math.floor(Number(epoch[3]))}`
};
}
/** What this reads of a JPL Small-Body Database answer (`sbdb.api?sstr=…&phys-par=1&full-prec=1`). */
export interface SbdbAnswer {
orbit: { epoch: string; elements: Array<{ name: string; value: string | null }> };
phys_par?: Array<{ name: string; value: string | null }>;
}
export interface SmallBody extends MeanOrbit {
radiusKm?: number;
rotationPeriodHours?: number;
}
/**
* A dwarf planet from the Small-Body Database: its osculating heliocentric elements against the
* J2000 ecliptic, the frame Standish's are in, carried round at their own mean motion n with
* nothing turning. Standish's tables stop at Pluto and JPL publishes no mean elements for the
* others, so these are exact on their epoch and drift from it — for Ceres, whose orbit Jupiter
* pulls on, by degrees within decades; see the ETL's check against Horizons.
*
* Radius and spin come from the same answer where it has them: half the published diameter, and
* the rotation period, in hours.
*/
export function parseSmallBodyElements(answer: SbdbAnswer): SmallBody {
const element = (name: string): number => {
const value = Number(answer.orbit.elements.find((candidate) => candidate.name === name)?.value ?? NaN);
if (!Number.isFinite(value)) {
throw new Error(`The SBDB answer has no element ${name}.`);
}
return value;
};
const physical = (name: string): number | undefined => {
const value = Number(answer.phys_par?.find((candidate) => candidate.name === name)?.value ?? NaN);
return Number.isFinite(value) ? value : undefined;
};
const epochJd = Number(answer.orbit.epoch);
const epoch = new Date((epochJd - 2440587.5) * 86400000);
const diameterKm = physical('diameter');
const rotationPeriodHours = physical('rot_per');
return {
orbit: {
semiMajorAxisAu: element('a'),
eccentricity: element('e'),
inclinationDeg: element('i'),
longitudeOfAscendingNodeDeg: element('om'),
argumentOfPeriapsisDeg: element('w'),
meanAnomalyAtEpochDeg: element('ma'),
epochJd
},
rates: { meanMotionDegPerDay: element('n'), longitudeOfAscendingNodeDegPerDay: 0, argumentOfPeriapsisDegPerDay: 0 },
orbitSource: `JPL SBDB osculating elements, epoch ${epoch.getUTCFullYear()} ${MONTHS[epoch.getUTCMonth()]} ${epoch.getUTCDate()}`,
...(diameterKm !== undefined ? { radiusKm: diameterKm / 2 } : {}),
...(rotationPeriodHours !== undefined ? { rotationPeriodHours } : {})
};
}
@@ -0,0 +1,200 @@
import { describe, expect, it } from 'vitest';
import { meanElementsAt } from './kepler';
import { orbitalTermsOfPrimeMeridian, orientationAt, parsePckRotationalElements } from './rotational-elements';
import { MeanElementRates, OrbitalElements, RotationalElements } from '../models/body.model';
// Excerpts of pck00011.tpc as NAIF publishes it: prose, then data blocks.
const KERNEL = String.raw`KPL/PCK
The portion of the file preceding the first data block is treated
as a comment.
\begindata
BODY399_POLE_RA = ( 0. -0.641 0. )
BODY399_POLE_DEC = ( 90. -0.557 0. )
BODY399_PM = ( 190.147 360.9856235 0. )
\begintext
A data block starts with the \begindata token only when that token
sits on a line by itself, so BODY399_PM = ( 1 2 3 ) here is prose.
\begindata
BODY301_POLE_RA = ( 269.9949 0.0031 0. )
BODY301_POLE_DEC = ( 66.5392 0.0130 0. )
BODY301_PM = ( 38.3213 13.17635815 -1.4D-12 )
BODY899_POLE_RA = ( 299.36 0. 0. )
BODY899_POLE_DEC = ( 43.46 0. 0. )
BODY899_PM = ( 249.978 541.1397757 0. )
BODY899_NUT_PREC_RA = ( 0.70 0. 0. 0. 0. 0. 0. 0. )
BODY899_NUT_PREC_DEC = ( -0.51 0. 0. 0. 0. 0. 0. 0. )
BODY899_NUT_PREC_PM = ( -0.48 0. 0. 0. 0. 0. 0. 0. )
BODY8_NUT_PREC_ANGLES = ( 357.85 52.316
323.92 62606.6 )
BODY199_POLE_RA = ( 281.0103 -0.0328 0. )
BODY199_POLE_DEC = ( 61.4155 -0.0049 0. )
BODY199_PM = ( 329.5988 6.1385108 0. )
BODY199_NUT_PREC_RA = ( 0. 0. )
BODY199_NUT_PREC_DEC = ( 0. 0. )
BODY199_NUT_PREC_PM = ( 0.01067257
-0.00112309 )
BODY1_NUT_PREC_ANGLES = ( 174.7910857 0.14947253587500003E+06
349.5821714 0.29894507175000006E+06 )
BODY401_POLE_RA = ( 317.67071657 -0.10844326 0. )
BODY401_POLE_DEC = ( 52.88627266 -0.06134706 0. )
BODY401_PM = ( 35.18774440 1128.84475928
9.536137031212154e-09 )
BODY401_NUT_PREC_RA = ( -1.78428399 )
BODY401_NUT_PREC_DEC = ( -1.07516537 )
BODY401_NUT_PREC_PM = ( 1.42421769
-1.143 )
BODY4_MAX_PHASE_DEGREE = 2
BODY4_NUT_PREC_ANGLES = (
190.72646643 15917.10818695 0
189.63271560 41215158.18420050 12.711923222 )
\begintext
`;
describe('parsePckRotationalElements', () => {
it('reads a pole and a prime meridian from the data blocks, not from the prose around them', () => {
expect(parsePckRotationalElements(KERNEL, 399)).toEqual({
elements: { poleRaDeg: [0, -0.641, 0], poleDecDeg: [90, -0.557, 0], primeMeridianDeg: [190.147, 360.9856235, 0] },
skippedDeg: []
});
});
it('reads the exponent the Fortran way, as the Moon’s quadratic is written', () => {
expect(parsePckRotationalElements(KERNEL, 301)!.elements.primeMeridianDeg).toEqual([38.3213, 13.17635815, -1.4e-12]);
});
it('pairs each periodic term with its system’s angle', () => {
expect(parsePckRotationalElements(KERNEL, 899)!.elements.terms).toEqual([{ angleDeg: [357.85, 52.316], ra: 0.7, dec: -0.51, pm: -0.48 }]);
});
it('reads angles to the degree the system states, Phobos’s quadratic among them', () => {
expect(parsePckRotationalElements(KERNEL, 401)!.elements.terms).toEqual([
{ angleDeg: [190.72646643, 15917.10818695, 0], ra: -1.78428399, dec: -1.07516537, pm: 1.42421769 },
{ angleDeg: [189.6327156, 41215158.1842005, 12.711923222], ra: 0, dec: 0, pm: -1.143 }
]);
});
it('leaves out a term under a hundredth of a degree, and says how large it was', () => {
const mercury = parsePckRotationalElements(KERNEL, 199)!;
expect(mercury.elements.terms).toEqual([{ angleDeg: [174.7910857, 149472.53587500003], ra: 0, dec: 0, pm: 0.01067257 }]);
expect(mercury.skippedDeg).toEqual([0.00112309]);
});
it('gives nothing for a body the kernel has no model for', () => {
expect(parsePckRotationalElements(KERNEL, 802)).toBeUndefined();
});
});
describe('orientationAt', () => {
const J2000 = 2451545.0;
it('turns the prime meridian at its rate per day and moves the pole at its rate per century', () => {
const earth = parsePckRotationalElements(KERNEL, 399)!.elements;
const epoch = orientationAt(earth, J2000);
expect([epoch.poleRaDeg, epoch.poleDecDeg]).toEqual([0, 90]);
expect(epoch.primeMeridianDeg).toBeCloseTo(190.147, 9);
const century = orientationAt(earth, J2000 + 36525);
expect(century.poleRaDeg).toBeCloseTo(-0.641, 12);
expect(century.poleDecDeg).toBeCloseTo(90 - 0.557, 12);
expect(orientationAt(earth, J2000 + 1).primeMeridianDeg).toBeCloseTo(190.147 + 360.9856235 - 360, 9);
});
it('adds a term as a sine to the right ascension and the meridian and a cosine to the declination', () => {
const neptune = parsePckRotationalElements(KERNEL, 899)!.elements;
const days = 9000;
const angle = ((357.85 + (52.316 * days) / 36525) * Math.PI) / 180;
const drawn = orientationAt(neptune, J2000 + days);
expect(drawn.poleRaDeg).toBeCloseTo(299.36 + 0.7 * Math.sin(angle), 12);
expect(drawn.poleDecDeg).toBeCloseTo(43.46 - 0.51 * Math.cos(angle), 12);
expect(drawn.primeMeridianDeg).toBeCloseTo((249.978 + 541.1397757 * days - 0.48 * Math.sin(angle)) % 360, 6);
});
it('carries the quadratic in the meridian and in the angle, which is how Phobos falls inward', () => {
const phobos = parsePckRotationalElements(KERNEL, 401)!.elements;
const days = 36525;
const first = (190.72646643 + 15917.10818695) * (Math.PI / 180);
const second = (189.6327156 + 41215158.1842005 + 12.711923222) * (Math.PI / 180);
const expected = 35.1877444 + 1128.84475928 * days + 9.536137031212154e-9 * days * days + 1.42421769 * Math.sin(first) - 1.143 * Math.sin(second);
expect(orientationAt(phobos, J2000 + days).primeMeridianDeg).toBeCloseTo(((expected % 360) + 360) % 360, 5);
});
});
describe('orbitalTermsOfPrimeMeridian', () => {
const J2000 = 2451545.0;
// Mimas's and Phobos's rows and W as pck00011.tpc and JPL's table give them, with each mean
// motion set to W's rate, so that only the terms can part the two.
const MIMAS: RotationalElements = {
poleRaDeg: [40.66, -0.036],
poleDecDeg: [83.52, -0.004],
primeMeridianDeg: [333.46, 381.994555, 0],
terms: [
{ angleDeg: [177.4, -36505.5], ra: 13.56, dec: -1.53, pm: -13.48 },
{ angleDeg: [316.45, 506.2], ra: 0, dec: 0, pm: -44.85 }
]
};
const PHOBOS: RotationalElements = {
poleRaDeg: [317.67071657, -0.10844326, 0],
poleDecDeg: [52.88627266, -0.06134706, 0],
primeMeridianDeg: [35.1877444, 1128.84475928, 9.536137031212154e-9]
};
const orbit = (epochJd: number): OrbitalElements => ({
semiMajorAxisAu: 0.001,
eccentricity: 0.02,
inclinationDeg: 1.5,
longitudeOfAscendingNodeDeg: 170,
argumentOfPeriapsisDeg: 60,
meanAnomalyAtEpochDeg: 10,
epochJd
});
/** The moon's mean longitude less W, which a locked moon holds still whatever the date. */
function lead(elements: RotationalElements, epochJd: number, angleRate: number | undefined, jd: number): number {
const { meanAnomalyTerms, meanMotionDegPerDay, meanAnomalyDeg } = orbitalTermsOfPrimeMeridian(elements, epochJd, angleRate);
const start = orbit(epochJd);
const rates: MeanElementRates = {
meanMotionDegPerDay: elements.primeMeridianDeg[1] + meanMotionDegPerDay,
longitudeOfAscendingNodeDegPerDay: -1,
argumentOfPeriapsisDegPerDay: 2,
meanAnomalyTerms
};
const moved = meanElementsAt({ ...start, meanAnomalyAtEpochDeg: start.meanAnomalyAtEpochDeg + meanAnomalyDeg }, rates, jd);
const longitude = moved.longitudeOfAscendingNodeDeg + moved.argumentOfPeriapsisDeg + moved.meanAnomalyAtEpochDeg;
// W with the pole's nodding term left out: that one is the pole's, not the orbit's.
const w = orientationAt({ ...elements, terms: elements.terms?.filter((term) => term.angleDeg[1] === angleRate) }, jd).primeMeridianDeg;
return (((longitude - w) % 360) + 540) % 360 - 180;
}
it('moves Mimas along its orbit by the libration its W carries, over the 71 years it takes', () => {
const atEpoch = lead(MIMAS, J2000, 506.2, J2000);
for (const years of [-130, -40, 17.8, 35.5, 100]) {
expect(lead(MIMAS, J2000, 506.2, J2000 + years * 365.25)).toBeCloseTo(atEpoch, 8);
}
});
it('speeds Phobos up by the tidal quadratic its W carries about J2000, from a row whose epoch is 1950', () => {
const epoch = 2433282.5;
const atEpoch = lead(PHOBOS, epoch, undefined, epoch);
for (const years of [-150, 0, 50, 100, 150, 400]) {
expect(lead(PHOBOS, epoch, undefined, J2000 + years * 365.25)).toBeCloseTo(atEpoch, 6);
}
});
it('refuses a term W does not carry', () => {
expect(() => orbitalTermsOfPrimeMeridian(PHOBOS, J2000, 506.2)).toThrow();
});
});
+171
View File
@@ -0,0 +1,171 @@
import { RotationalElements } from '../models/body.model';
/**
* Reads the IAU WGCCRE 2015 rotational elements from NAIF's text kernel `pck00011.tpc`, which the
* ETL fetches (see `tools/etl/lib/pck.ts`), and evaluates them at a date.
*/
const J2000_JD = 2451545.0;
const DAYS_PER_JULIAN_CENTURY = 36525;
const DEG_TO_RAD = Math.PI / 180;
/**
* The smallest periodic term kept, in degrees. A term turns the body, or tips its pole, by at most
* its amplitude, and the largest a body is ever drawn is Jupiter filling the screen at 641 px of
* radius, where 0.01 degrees moves a point on its surface by 0.11 px. In `pck00011.tpc` this
* leaves out 32 terms: Mercury's four smaller librations (0.0011 degrees and less), eight of the
* Moon's thirteen (0.0072 and less), the thirteen short-period terms of Mars's pole and meridian
* (0.00024 and less; its three 0.42-1.59 degree long-period ones stay), one of Phobos's (0.0063),
* Jupiter's five (0.0022 and less) and one of Europa's (0.009). Mimas's 44.85-degree libration,
* Triton's 32-degree precession and Miranda's 4.4 are kept, down to Triton's 0.01.
*/
export const MIN_PERIODIC_TERM_DEG = 0.01;
/**
* Every `NAME = ( values )` assignment in the kernel's data blocks. A data block runs from a line
* holding only `\begindata` to one holding only `\begintext`; the kernel's own prose mentions both
* tokens mid-sentence, which is why they are only read alone on a line. Exponents are written
* the Fortran way, `-1.4D-12`.
*/
function pckVariables(text: string): Map<string, number[]> {
const data = text
.split(/^\s*\\begindata\s*$/m)
.slice(1)
.map((block) => block.split(/^\s*\\begintext\s*$/m)[0])
.join('\n');
const variables = new Map<string, number[]>();
for (const [, name, value] of data.matchAll(/(\w+)\s*=\s*(\([^)]*\)|\S+)/g)) {
variables.set(
name,
value
.replace(/[()]/g, ' ')
.trim()
.split(/[\s,]+/)
.filter(Boolean)
.map((token) => Number(token.replace(/d/i, 'e')))
);
}
return variables;
}
/**
* One body's elements, by its NAIF id: 399 for Earth, 301 for the Moon, 2000001 for Ceres.
* Undefined where the kernel has none.
*
* The periodic terms' angles belong to the planet's whole system, `BODY5_NUT_PREC_ANGLES` for
* Jupiter and its moons, each a polynomial in T whose degree `BODYn_MAX_PHASE_DEGREE` gives: 1
* unless stated, 2 for Mars, where Phobos's angle carries the tidal acceleration that is drawing
* it in. A term is kept if any of its three amplitudes reaches {@link MIN_PERIODIC_TERM_DEG};
* the largest amplitude of each term left out comes back in `skippedDeg`, for the ETL to say so.
*/
export function parsePckRotationalElements(text: string, naifId: number): { elements: RotationalElements; skippedDeg: number[] } | undefined {
const variables = pckVariables(text);
const poleRaDeg = variables.get(`BODY${naifId}_POLE_RA`);
const poleDecDeg = variables.get(`BODY${naifId}_POLE_DEC`);
const primeMeridianDeg = variables.get(`BODY${naifId}_PM`);
if (!poleRaDeg || !poleDecDeg || !primeMeridianDeg) {
return undefined;
}
if (![...poleRaDeg, ...poleDecDeg, ...primeMeridianDeg].every(Number.isFinite)) {
throw new Error(`Body ${naifId}'s pole or prime meridian did not parse.`);
}
const ra = variables.get(`BODY${naifId}_NUT_PREC_RA`) ?? [];
const dec = variables.get(`BODY${naifId}_NUT_PREC_DEC`) ?? [];
const pm = variables.get(`BODY${naifId}_NUT_PREC_PM`) ?? [];
const system = naifId < 1000 ? Math.floor(naifId / 100) : undefined;
const angles = system === undefined ? [] : (variables.get(`BODY${system}_NUT_PREC_ANGLES`) ?? []);
const coefficients = (variables.get(`BODY${system}_MAX_PHASE_DEGREE`)?.[0] ?? 1) + 1;
const terms: NonNullable<RotationalElements['terms']> = [];
const skippedDeg: number[] = [];
for (let index = 0; index < Math.max(ra.length, dec.length, pm.length); index++) {
const term = { ra: ra[index] ?? 0, dec: dec[index] ?? 0, pm: pm[index] ?? 0 };
const largest = Math.max(Math.abs(term.ra), Math.abs(term.dec), Math.abs(term.pm));
if (largest === 0) {
continue;
}
if (largest < MIN_PERIODIC_TERM_DEG) {
skippedDeg.push(largest);
continue;
}
const angleDeg = angles.slice(index * coefficients, (index + 1) * coefficients);
if (angleDeg.length !== coefficients || !angleDeg.every(Number.isFinite)) {
throw new Error(`Body ${naifId}'s periodic term ${index + 1} has no angle among BODY${system}_NUT_PREC_ANGLES.`);
}
terms.push({ angleDeg, ...term });
}
return {
elements: { poleRaDeg, poleDecDeg, primeMeridianDeg, ...(terms.length > 0 ? { terms } : {}) },
skippedDeg
};
}
/**
* The Sun's, which is no `BodyRecord` but the system's star marker: NAIF body 10 in pck00011.tpc,
* the WGCCRE 2015 pole at RA 286.13, Dec 63.87 and W = 84.176 + 14.1844 d, a sidereal day of 25.38
* days at the Carrington latitude. The ETL checks them against the kernel.
*/
export const SUN_ROTATIONAL_ELEMENTS: RotationalElements = { poleRaDeg: [286.13, 0, 0], poleDecDeg: [63.87, 0, 0], primeMeridianDeg: [84.176, 14.1844, 0] };
function polynomial(coefficients: readonly number[], x: number): number {
return (coefficients[0] ?? 0) + (coefficients[1] ?? 0) * x + (coefficients[2] ?? 0) * x * x;
}
/** The pole's right ascension and declination and the prime meridian W, in degrees, at a TDB Julian date. */
export function orientationAt(elements: RotationalElements, jdTdb: number): { poleRaDeg: number; poleDecDeg: number; primeMeridianDeg: number } {
const days = jdTdb - J2000_JD;
const centuries = days / DAYS_PER_JULIAN_CENTURY;
let poleRaDeg = polynomial(elements.poleRaDeg, centuries);
let poleDecDeg = polynomial(elements.poleDecDeg, centuries);
let primeMeridianDeg = polynomial(elements.primeMeridianDeg, days);
for (const term of elements.terms ?? []) {
const angle = polynomial(term.angleDeg, centuries) * DEG_TO_RAD;
poleRaDeg += term.ra * Math.sin(angle);
poleDecDeg += term.dec * Math.cos(angle);
primeMeridianDeg += term.pm * Math.sin(angle);
}
return { poleRaDeg, poleDecDeg, primeMeridianDeg: ((primeMeridianDeg % 360) + 360) % 360 };
}
/**
* What a locked moon's W says of its going round that a row of mean elements leaves out, as terms
* of that row. W follows the moon's mean longitude, so a term of W that is the moon running ahead
* of and behind its mean motion, rather than its pole nodding, is its orbit's too. Two are here,
* and JPL's satellite table has a column for neither: Mimas's -44.85 degrees and Tethys's +2.23 on
* the angle that turns 506.2 degrees a century, the 71-year libration of their 4:2 resonance, and
* Phobos's quadratic, 12.72 degrees per century squared about J2000, the tidal acceleration
* drawing it in. Carried by W and not by the orbit, they left the drawn Mimas up to 45 degrees from
* where Horizons has it and its face as far from Saturn, and Phobos 11 degrees out by 2100.
*
* `angleRateDegPerCentury` names the term by its angle's rate; W's quadratic, where it has one, is
* always taken. Both come back about `epochJd`, which is where `meanAnomalyTerms` counts T from:
* the sine as its `c` and `s`, and the quadratic re-centred from J2000 onto that epoch, as `b` plus
* what the re-centring adds to the mean motion and to the mean anomaly at the epoch.
*/
export function orbitalTermsOfPrimeMeridian(
elements: RotationalElements,
epochJd: number,
angleRateDegPerCentury?: number
): { meanAnomalyTerms: { b: number; c: number; s: number; f: number }; meanMotionDegPerDay: number; meanAnomalyDeg: number } {
const epochCenturies = (epochJd - J2000_JD) / DAYS_PER_JULIAN_CENTURY;
// W turns clockwise about the pole the IAU names where its rate is negative; the orbit does not.
const sense = Math.sign(elements.primeMeridianDeg[1]);
const quadratic = sense * (elements.primeMeridianDeg[2] ?? 0) * DAYS_PER_JULIAN_CENTURY * DAYS_PER_JULIAN_CENTURY;
let sine = { c: 0, s: 0, f: 0 };
if (angleRateDegPerCentury !== undefined) {
const term = elements.terms?.find((candidate) => candidate.angleDeg[1] === angleRateDegPerCentury);
if (!term || (term.angleDeg[2] ?? 0) !== 0) {
throw new Error(`No term of W turns linearly at ${angleRateDegPerCentury} degrees a century.`);
}
const phase = (term.angleDeg[0] + term.angleDeg[1] * epochCenturies) * DEG_TO_RAD;
sine = { c: sense * term.pm * Math.sin(phase), s: sense * term.pm * Math.cos(phase), f: term.angleDeg[1] };
}
// q (T + T0)², T from the epoch and T0 the epoch from J2000, is q T² + 2 q T0 T + q T0².
return {
meanAnomalyTerms: { b: quadratic, ...sine },
meanMotionDegPerDay: (2 * quadratic * epochCenturies) / DAYS_PER_JULIAN_CENTURY,
meanAnomalyDeg: quadratic * epochCenturies * epochCenturies
};
}
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest';
import { jumpLinkSegments, minimumRangeBetween, routeBetween } from './jump-links';
import { answerRouting, indexCatalogue } from './routing';
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
/** Stars a parsec apart along x, then a gap of 5 pc to one more. */
const POINTS = [...Array.from({ length: 5 }, (_, i) => ({ id: 10 + i, x: i, y: 0, z: 0 })), { id: 99, x: 9, y: 0, z: 0 }];
function catalogue() {
return {
kind: 'catalogue' as const,
ids: Int32Array.from(POINTS, (point) => point.id),
positions: Float32Array.from(POINTS.flatMap((point) => [point.x, point.y, point.z]))
};
}
describe('indexCatalogue', () => {
it('indexes the catalogue as it was packed, id by id', () => {
const index = indexCatalogue(catalogue());
for (const point of POINTS) {
expect(index.point(point.id)).toEqual(point);
}
});
});
/** The same index, counting the neighbour queries a search makes through it. */
class CountingNeighbourhood extends StarNeighbourhood {
queries = 0;
override forEachWithin(id: number, radiusPc: number, visit: (neighbour: StarPoint, distancePc: number) => void): void {
this.queries++;
super.forEachWithin(id, radiusPc, visit);
}
}
describe('answerRouting', () => {
const index = indexCatalogue(catalogue());
const direct = new StarNeighbourhood(POINTS);
it('answers a route the range allows, with nothing to raise it to', () => {
const answer = answerRouting(index, { kind: 'route', requestId: 7, fromId: 10, toId: 14, rangePc: 1.5, ceilingPc: 8 });
expect(answer).toEqual({ kind: 'route', requestId: 7, route: routeBetween(direct, 10, 14, 1.5).route, neededRangePc: null, gaveUp: false, least: true });
});
it('answers a route the range does not allow with the range that would', () => {
const answer = answerRouting(index, { kind: 'route', requestId: 8, fromId: 10, toId: 99, rangePc: 1.5, ceilingPc: 8 });
expect(answer).toEqual({ kind: 'route', requestId: 8, route: null, neededRangePc: minimumRangeBetween(direct, 10, 99, 8).rangePc, gaveUp: false, least: true });
expect(answer.kind === 'route' && answer.neededRangePc).toBeCloseTo(5, 1);
});
it('passes on that the search gave up, rather than reporting no route', () => {
// A crowd larger than a search's budget around the departure, and a destination nothing reaches:
// the answer is "it gave up", and the scene has to be able to tell that from "there is none".
let seed = 5;
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 10 - 5;
const crowd: StarPoint[] = Array.from({ length: 45000 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
const knot = new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...crowd, { id: 99, x: 500, y: 0, z: 0 }], 0.5);
const answer = answerRouting(knot, { kind: 'route', requestId: 12, fromId: 0, toId: 99, rangePc: 0.5, ceilingPc: 0.5 });
expect(answer).toMatchObject({ route: null, neededRangePc: null, gaveUp: true });
});
it('asks the ceiling its question once, rather than searching it again to answer it', () => {
// At the panel's widest range the refused route and the range search are the same question, run
// with the same arguments over the same index: the second pays the whole budget for the answer
// the first already gave.
const counting = new CountingNeighbourhood(POINTS);
const oneSearch = new CountingNeighbourhood(POINTS);
routeBetween(oneSearch, 10, 99, 3);
const answer = answerRouting(counting, { kind: 'route', requestId: 11, fromId: 10, toId: 99, rangePc: 3, ceilingPc: 3 });
expect(answer).toMatchObject({ route: null, neededRangePc: null });
expect(counting.queries).toBe(oneSearch.queries);
});
it('offers nothing to raise to when even the ceiling does not reach', () => {
const answer = answerRouting(index, { kind: 'route', requestId: 9, fromId: 10, toId: 99, rangePc: 1.5, ceilingPc: 3 });
expect(answer).toMatchObject({ route: null, neededRangePc: null });
});
it('answers the graph as the segments it draws', () => {
const answer = answerRouting(index, { kind: 'links', requestId: 3, rangePc: 1.5, drawn: Uint32Array.from(POINTS.keys()) });
expect(answer.kind).toBe('links');
expect(answer.requestId).toBe(3);
expect(answer.kind === 'links' && linkEnds(answer.segments)).toEqual(linkEnds(jumpLinkSegments(direct, 1.5)));
});
it('links only the drawn stars, including a pair exactly the range apart', () => {
// Stars at x = 0, 1, 2 and 4 drawn; the one at 3, which would bridge 2 and 4, is not. At 1 pc
// every link is exactly the range long, and the cells are exactly the range wide.
const answer = answerRouting(index, { kind: 'links', requestId: 4, rangePc: 1, drawn: Uint32Array.of(0, 1, 2, 4) });
expect(answer.kind === 'links' && linkEnds(answer.segments)).toEqual(['0-1', '1-2']);
});
});
/** Each link as its two ends' x, lower first, in order: the pairs, whatever order they were walked in. */
function linkEnds(segments: Float32Array): string[] {
const ends: string[] = [];
for (let at = 0; at < segments.length; at += 6) {
ends.push([segments[at], segments[at + 3]].sort((a, b) => a - b).join('-'));
}
return ends.sort();
}
+74
View File
@@ -0,0 +1,74 @@
/**
* The route questions the map asks of the whole catalogue, as messages: what a worker is sent,
* what it sends back, and the one function that turns the first into the second.
*
* Kept apart from the worker itself so it runs the same on either side of the thread boundary.
* The scene asks through `RoutingClient`, which hands these to a Web Worker where one exists and
* answers them in place where one does not.
*/
import { jumpLinkSegments, LinkBudget, minimumRangeBetween, Route, routeBetween } from './jump-links';
import { StarNeighbourhood } from './star-neighbourhood';
/** The catalogue, sent once: ids, and positions packed three to a star in the same order. */
export interface RoutingCatalogue {
readonly kind: 'catalogue';
readonly ids: Int32Array;
readonly positions: Float32Array;
}
export type RoutingRequest =
| { readonly kind: 'route'; readonly requestId: number; readonly fromId: number; readonly toId: number; readonly rangePc: number; readonly ceilingPc: number }
/**
* `drawn` is the stars the map is drawing, as positions in the catalogue that was sent: only they
* are linked. `budget`, where given, keeps only the links nearest the view that fit its length.
*/
| { readonly kind: 'links'; readonly requestId: number; readonly rangePc: number; readonly drawn: Uint32Array; readonly budget?: LinkBudget };
export type RoutingResponse =
/**
* Two searches, and two things they can fail to prove, kept apart because they are printed as
* different sentences. `gaveUp` is about the range that was asked for: true when that search
* spent its budget rather than looking everywhere the range reaches. `least` is about the search
* for a range that would work: true when it looked everywhere up to the ceiling, so `null` there
* means no chain exists rather than none was found.
*/
| {
readonly kind: 'route';
readonly requestId: number;
readonly route: Route | null;
readonly neededRangePc: number | null;
readonly gaveUp: boolean;
readonly least: boolean;
}
| { readonly kind: 'links'; readonly requestId: number; readonly segments: Float32Array }
/** The question threw in the worker. Sent back so the request settles instead of waiting for good. */
| { readonly kind: 'failed'; readonly requestId: number; readonly message: string };
/** A spatial index over a catalogue sent as a {@link RoutingCatalogue}. */
export function indexCatalogue({ ids, positions }: RoutingCatalogue): StarNeighbourhood {
return new StarNeighbourhood(Array.from(ids, (id, i) => ({ id, x: positions[i * 3], y: positions[i * 3 + 1], z: positions[i * 3 + 2] })));
}
/**
* Answers one request. A route that cannot be made comes back with the range that would make one,
* searched no wider than `ceilingPc`, so a refusal is usually also an offer — unless the searches
* gave up, which is reported rather than passed off as "there is no route".
*/
export function answerRouting(index: StarNeighbourhood, request: RoutingRequest): RoutingResponse {
if (request.kind === 'links') {
// An index of its own over the drawn stars, in cells as wide as the range, so each cell is
// paired with its immediate neighbours only: 14 cells a cell at 8 pc rather than 63.
const drawn = new StarNeighbourhood(Array.from(request.drawn, (at) => index.pointAt(at)), request.rangePc);
return { kind: 'links', requestId: request.requestId, segments: jumpLinkSegments(drawn, request.rangePc, request.budget) };
}
const { route, gaveUp } = routeBetween(index, request.fromId, request.toId, request.rangePc);
// At the ceiling the question has just been asked: the range search would repeat it, identically
// and at the same cost, before bisecting below it.
if (route || request.rangePc >= request.ceilingPc) {
// Asked at the ceiling, the one search answers both questions.
return { kind: 'route', requestId: request.requestId, route, neededRangePc: null, gaveUp: !route && gaveUp, least: !gaveUp };
}
const needed = minimumRangeBetween(index, request.fromId, request.toId, request.ceilingPc);
return { kind: 'route', requestId: request.requestId, route: null, neededRangePc: needed.rangePc, gaveUp, least: needed.least };
}
+26
View File
@@ -0,0 +1,26 @@
/// <reference lib="webworker" />
import { answerRouting, indexCatalogue, RoutingCatalogue, RoutingRequest } from './routing';
import { StarNeighbourhood } from './star-neighbourhood';
/**
* Walks routes and builds the jump-link graph off the main thread. A search to a star 236 pc
* away, and the range it would need when there is none, can take seconds; a graph of the drawn
* stars at 8 pc is hundreds of thousands of links. On the page's own thread either stops the map
* for as long as it runs.
*/
let index: StarNeighbourhood | undefined;
addEventListener('message', ({ data }: MessageEvent<RoutingCatalogue | RoutingRequest>) => {
if (data.kind === 'catalogue') {
index = indexCatalogue(data);
return;
}
// The catalogue is always the first message, and a worker's messages arrive in order.
try {
const response = answerRouting(index!, data);
postMessage(response, response.kind === 'links' ? [response.segments.buffer] : []);
} catch (error) {
postMessage({ kind: 'failed', requestId: data.requestId, message: error instanceof Error ? error.message : String(error) });
}
});
+127 -4
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { raDegDecDistanceToXyz } from './coordinates'; import { raDegDecDistanceToXyz } from './coordinates';
import { StarRecord } from '../models/star.model'; import { StarRecord } from '../models/star.model';
import { directionCosine, isSameStar, mergeStarCatalogues } from './star-merge'; import { directionCosine, isSameStar, MERGE_ANGULAR_TOLERANCE_DEG, mergeStarCatalogues, placementDistancePc } from './star-merge';
/** A star at a given sky position and distance, which is how catalogues actually report them. */ /** A star at a given sky position and distance, which is how catalogues actually report them. */
function at(id: number, raDeg: number, decDeg: number, distancePc: number, overrides: Partial<StarRecord> = {}): StarRecord { function at(id: number, raDeg: number, decDeg: number, distancePc: number, overrides: Partial<StarRecord> = {}): StarRecord {
@@ -13,24 +13,70 @@ function at(id: number, raDeg: number, decDeg: number, distancePc: number, overr
const HIPPARCOS = { sourceId: 'hyg', parallaxPrecisionMas: 1 }; const HIPPARCOS = { sourceId: 'hyg', parallaxPrecisionMas: 1 };
const GAIA = { sourceId: 'gaia', parallaxPrecisionMas: 0.02 }; const GAIA = { sourceId: 'gaia', parallaxPrecisionMas: 0.02 };
/** Degrees of right ascension that span `arcsec` on the sky at declination `decDeg`. */
function arcsecOfRa(arcsec: number, decDeg: number): number {
return arcsec / 3600 / Math.cos((decDeg * Math.PI) / 180);
}
describe('isSameStar', () => { describe('isSameStar', () => {
it('matches two catalogues reporting the same star', () => { it('matches two catalogues reporting the same star', () => {
expect(isSameStar(at(1, 101.28, -16.71, 2.64), at(2, 101.28, -16.71, 2.63))).toBe(true); expect(isSameStar(at(1, 101.28, -16.71, 2.64), at(2, 101.28, -16.71, 2.63))).toBe(true);
}); });
it('matches within the angular tolerance and not beyond it', () => {
const toleranceArcsec = MERGE_ANGULAR_TOLERANCE_DEG * 3600;
expect(isSameStar(at(1, 200, 10, 100), at(2, 200 + arcsecOfRa(0.9 * toleranceArcsec, 10), 10, 100))).toBe(true);
expect(isSameStar(at(1, 200, 10, 100), at(2, 200 + arcsecOfRa(1.1 * toleranceArcsec, 10), 10, 100))).toBe(false);
});
it('tolerates the distance disagreement two parallaxes actually have', () => { it('tolerates the distance disagreement two parallaxes actually have', () => {
// Hipparcos and Gaia routinely differ by tens of per cent at a few hundred parsecs. That // Hipparcos and Gaia routinely differ by tens of per cent at a few hundred parsecs. That
// disagreement is the reason to prefer one of them, not evidence they are different stars. // disagreement is the reason to prefer one of them, not evidence they are different stars.
expect(isSameStar(at(1, 200, 10, 200), at(2, 200, 10, 260))).toBe(true); expect(isSameStar(at(1, 200, 10, 200), at(2, 200, 10, 260))).toBe(true);
}); });
it('keeps a bright primary out of the entry of its faint companion', () => {
// Gaia has no Sirius — it saturates — but has Sirius B, 6″ away at the same distance and ten
// magnitudes fainter. Direction and distance say "same star"; the brightness says otherwise.
const siriusB = at(1, 101.2875, -16.7161, 2.67, { name: 'Gaia DR3 2947050466531873024', magnitude: 8.5, source: 'gaia' });
const sirius = at(32263, 101.2875 + arcsecOfRa(6.1, -16.7161), -16.7161, 2.637, { name: 'Sirius', magnitude: -1.44 });
expect(isSameStar(siriusB, sirius)).toBe(false);
expect(isSameStar(siriusB, { ...sirius, magnitude: 8.6 })).toBe(true);
});
it('lets the folded entry be fainter, as a red star is in V, but not much brighter', () => {
// Wolf 359 is V 13.45 in HYG and G 11.0 in Gaia — the same star, 5″ apart on a Gliese
// position. Almach is V 2.1 and sits 10″ from γ² And, G 4.9: Gaia has no Almach, and its
// name must not land on the companion.
const wolf359 = at(1, 164.1, 7.0, 2.41, { name: 'Gaia DR3 3864972938605115520', magnitude: 11.0, source: 'gaia' });
expect(isSameStar(wolf359, at(118720, 164.1 + arcsecOfRa(5, 7), 7.0, 2.39, { name: 'Wolf 359', magnitude: 13.45 }))).toBe(true);
const gamma2And = at(2, 30.97, 42.33, 50, { name: 'Gaia DR3 346231302441905920', magnitude: 4.9, source: 'gaia' });
expect(isSameStar(gamma2And, at(9640, 30.97 + arcsecOfRa(9.9, 42.33), 42.33, 50, { name: 'Almach', magnitude: 2.1 }))).toBe(false);
});
it('does not match two different stars that happen to be at the same distance', () => { it('does not match two different stars that happen to be at the same distance', () => {
expect(isSameStar(at(1, 200, 10, 200), at(2, 200.5, 10, 200))).toBe(false); expect(isSameStar(at(1, 200, 10, 200), at(2, 200.5, 10, 200))).toBe(false);
}); });
it('does not match along a line of sight when the distances genuinely conflict', () => { it('takes two entries within three arcseconds for one star, whatever their distances say', () => {
// Same direction, one three times further away: a background star, not the same object. // HD 225021: 143.7 pc by its Hipparcos parallax, 239.4 by Gaia's, 0.01″ apart; HIP 82724:
expect(isSameStar(at(1, 200, 10, 100), at(2, 200, 10, 300))).toBe(false); // 3.7 pc by Hipparcos, 62.8 by Gaia, 2.3″ apart. A coincidence of direction that close is
// never chance at this depth; the parallax is what is wrong.
const gaia = at(1, 1.72, -8.9, 239.4, { name: 'Gaia DR3 395581679270412160', source: 'gaia' });
expect(isSameStar(gaia, at(213, 1.72 + arcsecOfRa(0.1, -8.9), -8.9, 143.7, { name: 'HD 225021' }))).toBe(true);
expect(isSameStar(at(2, 253.6, -38.1, 62.8, { source: 'gaia' }), at(82724, 253.6 + arcsecOfRa(2.3, -38.1), -38.1, 3.7))).toBe(true);
});
it('past those three arcseconds, does not match along a line of sight when the distances conflict', () => {
// Nearly the same direction, one three times further away: a background star, not the same object.
expect(isSameStar(at(1, 200, 10, 100), at(2, 200 + arcsecOfRa(5, 10), 10, 300))).toBe(false);
});
it('still hears the brightness inside those three arcseconds', () => {
// Ashlesha (ε Hya, V 3.38) has a companion 2.7″ away that Gaia does carry, three magnitudes
// fainter, while it does not carry Ashlesha. Direction alone would put the name on the companion.
const companion = at(1, 131.69, 6.42, 40, { name: 'Gaia DR3 1', magnitude: 6.7, source: 'gaia' });
expect(isSameStar(companion, at(43109, 131.69 + arcsecOfRa(2.7, 6.42), 6.42, 40, { name: 'Ashlesha', magnitude: 3.38 }))).toBe(false);
}); });
it('matches on direction rather than on 3D proximity', () => { it('matches on direction rather than on 3D proximity', () => {
@@ -72,6 +118,44 @@ describe('mergeStarCatalogues', () => {
expect(summary.duplicates).toBe(1); expect(summary.duplicates).toBe(1);
}); });
it('gives a matched star the better position and the name somebody gave it', () => {
// What a merge is for: Gaia knows where Proxima is to a fraction of a milliarcsecond and
// calls it by a nineteen-digit number; HYG knows its name, its spectral type and its V
// magnitude. Keeping one row whole loses half of that either way. The id follows the
// description, so a star HYG knows keeps its HYG id from one refresh to the next.
const hyg = at(70666, 217.4289, -62.6795, 1.2959, { name: 'Proxima Centauri', spectralType: 'M5Ve', magnitude: 11.01, colorIndex: 1.807 });
const gaia = at(1000064182, 217.4289, -62.6795, 1.302, { name: 'Gaia DR3 5853498713190525696', spectralType: 'Unknown', magnitude: 8.985, colorIndex: 3.805, source: 'gaia' });
const { stars, summary } = mergeStarCatalogues([{ ...HIPPARCOS, stars: [hyg] }, { ...GAIA, stars: [gaia] }]);
expect(stars).toEqual([{ ...hyg, x: gaia.x, y: gaia.y, z: gaia.z, source: 'gaia' }]);
expect(summary.duplicates).toBe(1);
});
it('keeps two entries of one source apart, however close they are', () => {
// Gaia resolves doubles Hipparcos saw as one star: two source ids 0.8″ apart are two stars,
// and only *another* catalogue can claim to have already listed either of them.
const { stars, summary } = mergeStarCatalogues([{ ...GAIA, stars: [at(1, 10, 10, 100), at(2, 10 + arcsecOfRa(0.8, 10), 10, 100)] }]);
expect(stars).toHaveLength(2);
expect(summary.duplicates).toBe(0);
});
it('folds an entry into the nearest match, and into each match once', () => {
// Gliese lists both components of a double; Gaia resolves them 0.8″ apart. Each HYG
// component must land on its own Gaia counterpart — not both on whichever the grid yields
// first, and not both on the same one.
const gaiaA = at(1, 10, 10, 2.68, { name: 'Gaia DR3 1', source: 'gaia' });
const gaiaB = at(2, 10 + arcsecOfRa(0.8, 10), 10, 2.68, { name: 'Gaia DR3 2', source: 'gaia' });
const a = at(118079, 10, 10, 2.63, { name: 'Gl 65A' });
const b = at(118080, 10 + arcsecOfRa(0.8, 10), 10, 2.63, { name: 'Gl 65B' });
const position = (star: StarRecord) => [star.x, star.y, star.z];
const nearest = mergeStarCatalogues([{ ...HIPPARCOS, stars: [b, a] }, { ...GAIA, stars: [gaiaA, gaiaB] }]);
expect(nearest.stars.map((star) => [star.name, ...position(star)])).toEqual([['Gl 65A', ...position(gaiaA)], ['Gl 65B', ...position(gaiaB)]]);
const onePlace = mergeStarCatalogues([{ ...HIPPARCOS, stars: [a, { ...b, x: a.x, y: a.y, z: a.z }] }, { ...GAIA, stars: [gaiaA, gaiaB] }]);
expect(onePlace.stars.map((star) => [star.name, ...position(star)])).toEqual([['Gl 65A', ...position(gaiaA)], ['Gl 65B', ...position(gaiaB)]]);
});
it('keeps a star the better catalogue does not reach', () => { it('keeps a star the better catalogue does not reach', () => {
// The point of merging rather than replacing: Gaia is more precise but not a superset of // The point of merging rather than replacing: Gaia is more precise but not a superset of
// everything, and a bright star it omits should not vanish from the map. // everything, and a bright star it omits should not vanish from the map.
@@ -123,6 +207,13 @@ describe('mergeStarCatalogues', () => {
]); ]);
expect(stars).toHaveLength(1); expect(stars).toHaveLength(1);
} }
// And the one edge the grid has to wrap: 3.6″ apart, either side of 0h.
const { stars } = mergeStarCatalogues([
{ ...HIPPARCOS, stars: [at(1, 359.9995, 0, 100)] },
{ ...GAIA, stars: [at(2, 0.0005, 0, 100)] }
]);
expect(stars).toHaveLength(1);
}); });
it('handles a single catalogue as a plain pass-through', () => { it('handles a single catalogue as a plain pass-through', () => {
@@ -146,3 +237,35 @@ describe('mergeStarCatalogues', () => {
expect(Date.now() - started).toBeLessThan(10000); expect(Date.now() - started).toBeLessThan(10000);
}); });
}); });
describe('placementDistancePc', () => {
it("draws a star both surveys measured at Gaia's distance", () => {
expect(placementDistancePc(120, 118.4, 250)).toBe(118.4);
});
// The case the old cut got wrong: Hipparcos inside, Gaia outside. Kept, at the distance Gaia
// gives, rather than at one a third short or dropped for having been misplaced.
it('keeps a star Hipparcos put inside the cutoff, where Gaia puts it, even past the cutoff', () => {
expect(placementDistancePc(200, 306, 250)).toBe(306);
});
// The mirror image: Hipparcos outside, Gaia inside. The Gaia download already holds the star,
// and keeping the HYG row is what lets the merge give that entry its name.
it('keeps a star only Gaia puts inside the cutoff', () => {
expect(placementDistancePc(262, 241, 250)).toBe(241);
});
it('keeps a star Gaia measured and Hipparcos gave no distance for', () => {
expect(placementDistancePc(undefined, 180, 250)).toBe(180);
});
it('falls back to Hipparcos where Gaia has no usable distance', () => {
expect(placementDistancePc(90, undefined, 250)).toBe(90);
});
it('drops a star both surveys put outside, or neither measured', () => {
expect(placementDistancePc(300, 410, 250)).toBeNull();
expect(placementDistancePc(300, undefined, 250)).toBeNull();
expect(placementDistancePc(undefined, undefined, 250)).toBeNull();
});
});
+135 -19
View File
@@ -1,3 +1,4 @@
import { isDesignation } from '../models/star-catalog';
import { StarRecord } from '../models/star.model'; import { StarRecord } from '../models/star.model';
/** /**
@@ -17,8 +18,47 @@ import { StarRecord } from '../models/star.model';
const DEG_TO_RAD = Math.PI / 180; const DEG_TO_RAD = Math.PI / 180;
/** Angular separation, in degrees, below which two entries are taken to be the same star. */ /**
export const MERGE_ANGULAR_TOLERANCE_DEG = 1 / 3600; * Angular separation, in degrees, below which two entries are taken to be the same star.
*
* Every source arrives here at epoch J2000.0 — HYG publishes it, Gaia is carried back to it with
* its own proper motions in `gaia.ts` — so what separates two entries of one star is measurement,
* not motion. Left at their own epochs, sixteen years of proper motion put Proxima's two entries
* 62″ apart and Barnard's 166″, and an arcsecond of tolerance kept every fast star twice while
* folding the slow ones.
*
* What measurement leaves is under an arcsecond for a Hipparcos position — 55 457 of the 56 000
* stars both catalogues hold — and up to tens of arcseconds for the Gliese-only entries HYG
* carries without Hipparcos astrometry: Wolf 359 sits 5″ from where Gaia has it, Ross 248 12″.
* Fifteen arcseconds takes those. The sky is sparse enough at this depth that shifting every
* entry a quarter of a degree finds only 16 chance neighbours within it, against 116 real ones
* between ten and fifteen; past twenty the two curves run together.
*/
export const MERGE_ANGULAR_TOLERANCE_DEG = 15 / 3600;
/**
* Angular separation, in degrees, under which the distances are not consulted. A coincidence of
* direction this close is never chance at this depth — the quarter-degree shift finds none under
* 3″ — so two entries this close are one star whatever their parallaxes say, and what they say
* is often a Hipparcos parallax off by half: 1 500 stars sat within this of their Gaia entry and
* were kept twice by the distance test, thirty of them at a false few parsecs from the Sun
* (HIP 82724 at 3.7 pc, where Gaia has it at 62.8). Brightness keeps its say at any separation,
* because a companion can sit this close: Ashlesha's is 2.7″ away and three magnitudes fainter.
*/
export const MERGE_CERTAIN_ANGULAR_TOLERANCE_DEG = 3 / 3600;
/**
* How much fainter, and how much brighter, an entry may be than the one it is folded into and
* still be the same star. Bands differ, and not symmetrically: a red dwarf is three magnitudes
* fainter in HYG's V than in Gaia's G, so the folded entry may be up to five fainter. A star is
* never much brighter in V than in G, though, and an entry a magnitude brighter than what is
* already at that spot is a primary Gaia does not carry — it saturates below G ≈ 3 — sitting
* beside its companion: Sirius 6″ from Sirius B and ten magnitudes brighter, Almach 10″ from
* γ² And, Alfirk 13″ from β Cep B. Without this the primary's name lands on the companion's
* entry, and the companion is gone.
*/
export const MERGE_FAINTER_TOLERANCE = 5;
export const MERGE_BRIGHTER_TOLERANCE = 1;
/** /**
* How far two distances may disagree, as a ratio, and still describe the same star. Generous on * How far two distances may disagree, as a ratio, and still describe the same star. Generous on
@@ -27,6 +67,28 @@ export const MERGE_ANGULAR_TOLERANCE_DEG = 1 / 3600;
*/ */
export const MERGE_DISTANCE_RATIO_TOLERANCE = 0.5; export const MERGE_DISTANCE_RATIO_TOLERANCE = 0.5;
/**
* Where to draw a star Hipparcos and Gaia both measured, and whether the map keeps it at all.
*
* Gaia's distance wherever it has a usable one, since its parallaxes are fifty times more
* precise; Hipparcos's otherwise. The two catalogues used to be cut at the same radius, each on
* its own distance, so a star Hipparcos put at 200 pc and Gaia at 300 was kept by one, never
* downloaded from the other, and drawn at 200. That was 83% of the HYG stars left without a
* Gaia counterpart, and at the median Hipparcos had them at two-thirds of Gaia's distance.
*
* Now a star either survey places inside `cutoffPc` is kept, and every kept star sits where the
* better measurement puts it, inside the cutoff or not. `null` for a star neither survey places
* inside, or that no survey gives a distance for.
*/
export function placementDistancePc(hipparcosPc: number | undefined, gaiaPc: number | undefined, cutoffPc: number): number | null {
const best = gaiaPc ?? hipparcosPc;
if (best === undefined) {
return null;
}
const inside = best <= cutoffPc || (hipparcosPc !== undefined && hipparcosPc <= cutoffPc);
return inside ? best : null;
}
export interface MergeCandidate { export interface MergeCandidate {
readonly sourceId: string; readonly sourceId: string;
/** Lower is better — the parallax precision this source measures with, in milliarcseconds. */ /** Lower is better — the parallax precision this source measures with, in milliarcseconds. */
@@ -36,7 +98,7 @@ export interface MergeCandidate {
export interface MergeSummary { export interface MergeSummary {
readonly total: number; readonly total: number;
/** Entries dropped because a better-measured catalogue already had that star. */ /** Entries folded into one a better-measured catalogue already had; see {@link combine}. */
readonly duplicates: number; readonly duplicates: number;
readonly bySource: Readonly<Record<string, number>>; readonly bySource: Readonly<Record<string, number>>;
} }
@@ -60,8 +122,13 @@ function distanceOf(star: StarRecord): number {
*/ */
const SKY_CELL_DEG = 0.5; const SKY_CELL_DEG = 0.5;
const RA_CELLS = 360 / SKY_CELL_DEG;
function cellKey(raDeg: number, decDeg: number): string { function cellKey(raDeg: number, decDeg: number): string {
return `${Math.floor(raDeg / SKY_CELL_DEG)}:${Math.floor(decDeg / SKY_CELL_DEG)}`; // Right ascension wraps: the cell after 359.5° is 0°, so a pair straddling 0h shares a
// neighbourhood rather than sitting 719 cells apart.
const raCell = ((Math.floor(raDeg / SKY_CELL_DEG) % RA_CELLS) + RA_CELLS) % RA_CELLS;
return `${raCell}:${Math.floor(decDeg / SKY_CELL_DEG)}`;
} }
function skyAngles(star: StarRecord): { raDeg: number; decDeg: number } { function skyAngles(star: StarRecord): { raDeg: number; decDeg: number } {
@@ -86,9 +153,13 @@ export function directionCosine(a: StarRecord, b: StarRecord): number {
return Math.max(-1, Math.min(1, ax * bx + ay * by + az * bz)); return Math.max(-1, Math.min(1, ax * bx + ay * by + az * bz));
} }
/** Whether two entries describe the same star: same direction, and distances not in conflict. */ /**
export function isSameStar(a: StarRecord, b: StarRecord): boolean { * Whether `entry` describes the star already `kept`: the same direction, the brightness not in
const [near, far] = [distanceOf(a), distanceOf(b)].sort((p, q) => p - q); * conflict and — unless the directions agree closely enough to settle it — the distance not in
* conflict either.
*/
export function isSameStar(kept: StarRecord, entry: StarRecord): boolean {
const [near, far] = [distanceOf(kept), distanceOf(entry)].sort((p, q) => p - q);
// The Sun sits at the origin of this coordinate system and so has no direction at all, which // The Sun sits at the origin of this coordinate system and so has no direction at all, which
// the angular test below cannot speak about. Every catalogue contains it, so without this the // the angular test below cannot speak about. Every catalogue contains it, so without this the
@@ -97,25 +168,53 @@ export function isSameStar(a: StarRecord, b: StarRecord): boolean {
return far === 0; return far === 0;
} }
const separationDeg = Math.acos(directionCosine(a, b)) / DEG_TO_RAD; const separationDeg = Math.acos(directionCosine(kept, entry)) / DEG_TO_RAD;
if (separationDeg > MERGE_ANGULAR_TOLERANCE_DEG) { if (separationDeg > MERGE_ANGULAR_TOLERANCE_DEG) {
return false; return false;
} }
const fainterBy = entry.magnitude - kept.magnitude;
if (fainterBy < -MERGE_BRIGHTER_TOLERANCE || fainterBy > MERGE_FAINTER_TOLERANCE) {
return false;
}
if (separationDeg <= MERGE_CERTAIN_ANGULAR_TOLERANCE_DEG) {
return true;
}
return (far - near) / near <= MERGE_DISTANCE_RATIO_TOLERANCE; return (far - near) / near <= MERGE_DISTANCE_RATIO_TOLERANCE;
} }
/**
* One entry from two of the same star: the position of the better-measured one — inserted first,
* so it is the one already `kept` — and the description of whichever knows the star as more than
* a catalogue number. HYG's "Proxima Centauri", "M5Ve" and V magnitude over Gaia's
* "Gaia DR3 5853498713190525696", "Unknown" and G; keeping either row whole loses half of that,
* and keeping Gaia's whole once cost the map 102 proper names and 32 000 spectral types. The id
* travels with the description, so a star HYG knows keeps its HYG id from one refresh to the next;
* `source` stays with the position, since that is what it records.
*/
function combine(kept: StarRecord, other: StarRecord): StarRecord {
const described = isDesignation(kept) && !isDesignation(other) ? other : kept;
return { ...described, x: kept.x, y: kept.y, z: kept.z, source: kept.source };
}
/** /**
* Unions the given catalogues, keeping one entry per star. * Unions the given catalogues, keeping one entry per star.
* *
* Sources are taken in order of how precisely they measure parallax, best first, and a star is * Sources are taken in order of how precisely they measure parallax, best first. An entry that a
* only added if no better-measured catalogue already has it. So where Gaia and Hipparcos * better-measured catalogue already has is folded into that entry — the nearest one within the
* overlap, the position is Gaia's; where only Hipparcos reaches, the star is still there. * tolerance, see {@link combine} for what each side keeps. Only entries from *other* sources
* count as already there: a catalogue does not list a star twice, so two of its own entries
* within the tolerance are two stars, typically a double that Gaia resolves and Hipparcos did
* not. Where only one source reaches, the star is still there.
*/ */
export function mergeStarCatalogues(candidates: readonly MergeCandidate[]): { stars: StarRecord[]; summary: MergeSummary } { export function mergeStarCatalogues(candidates: readonly MergeCandidate[]): { stars: StarRecord[]; summary: MergeSummary } {
const ordered = [...candidates].sort((a, b) => a.parallaxPrecisionMas - b.parallaxPrecisionMas); const ordered = [...candidates].sort((a, b) => a.parallaxPrecisionMas - b.parallaxPrecisionMas);
const merged: StarRecord[] = []; const merged: StarRecord[] = [];
const grid = new Map<string, StarRecord[]>(); const grid = new Map<string, number[]>();
// Entries that already absorbed one from a source, as `${index}/${source}`: a double that
// Gliese lists as two entries at one position has to land on two Gaia entries, not on one.
const taken = new Set<string>();
const bySource: Record<string, number> = {}; const bySource: Record<string, number> = {};
let duplicates = 0; let duplicates = 0;
@@ -123,24 +222,41 @@ export function mergeStarCatalogues(candidates: readonly MergeCandidate[]): { st
bySource[candidate.sourceId] = 0; bySource[candidate.sourceId] = 0;
for (const star of candidate.stars) { for (const star of candidate.stars) {
const { raDeg, decDeg } = skyAngles(star); const entry: StarRecord = { ...star, source: star.source ?? candidate.sourceId };
const alreadyPresent = neighbouringCells(raDeg, decDeg).some((key) => (grid.get(key) ?? []).some((existing) => isSameStar(existing, star))); const { raDeg, decDeg } = skyAngles(entry);
if (alreadyPresent) { let match: number | null = null;
let matchCosine = -1;
for (const key of neighbouringCells(raDeg, decDeg)) {
for (const index of grid.get(key) ?? []) {
const existing = merged[index];
if (existing.source === entry.source || taken.has(`${index}/${entry.source}`) || !isSameStar(existing, entry)) {
continue;
}
const cosine = directionCosine(existing, entry);
if (cosine > matchCosine) {
match = index;
matchCosine = cosine;
}
}
}
if (match !== null) {
merged[match] = combine(merged[match], entry);
taken.add(`${match}/${entry.source}`);
duplicates++; duplicates++;
continue; continue;
} }
const withSource: StarRecord = { ...star, source: star.source ?? candidate.sourceId }; const index = merged.push(entry) - 1;
merged.push(withSource);
bySource[candidate.sourceId]++; bySource[candidate.sourceId]++;
const key = cellKey(raDeg, decDeg); const key = cellKey(raDeg, decDeg);
const cell = grid.get(key); const cell = grid.get(key);
if (cell) { if (cell) {
cell.push(withSource); cell.push(index);
} else { } else {
grid.set(key, [withSource]); grid.set(key, [index]);
} }
} }
} }
@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest';
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
/** A line of stars one parsec apart along x, so every expected distance is an integer. */
function line(count: number): StarPoint[] {
return Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 }));
}
function ids(found: { id: number }[]): number[] {
return found.map((neighbour) => neighbour.id);
}
describe('StarNeighbourhood', () => {
it('names the nearest stars in order, and never the star itself', () => {
const index = new StarNeighbourhood(line(10));
expect(ids(index.nearest(4, 3))).toEqual([3, 5, 2]);
});
it('measures the separation it found each star by', () => {
const index = new StarNeighbourhood([
{ id: 1, x: 0, y: 0, z: 0 },
{ id: 2, x: 3, y: 4, z: 0 }
]);
expect(index.nearest(1, 1)[0].distancePc).toBeCloseTo(5);
});
it('reaches past its own cell for a star sitting alone in one', () => {
// 5 pc cells: these three are in three different cells, and the nearest is 12 pc out.
const index = new StarNeighbourhood([
{ id: 1, x: 0, y: 0, z: 0 },
{ id: 2, x: 12, y: 0, z: 0 },
{ id: 3, x: 40, y: 0, z: 0 }
]);
expect(ids(index.nearest(1, 2))).toEqual([2, 3]);
});
it('does not stop at the first ring that fills the list, where the next holds something closer', () => {
// The diagonal neighbour is in the ring-1 shell but 8.7 pc away; the one straight along x is
// in the ring-2 shell and only 6 pc away. Stopping at the first full ring would miss it.
const index = new StarNeighbourhood([
{ id: 1, x: 0, y: 0, z: 0 },
{ id: 2, x: 5, y: 5, z: 5 },
{ id: 3, x: 6, y: 0, z: 0 }
]);
expect(ids(index.nearest(1, 1))).toEqual([3]);
});
it('agrees with a brute-force scan over a pseudo-random cloud', () => {
// The property that matters: the grid is an optimisation, never a different answer.
let seed = 7;
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 100 - 50;
const cloud: StarPoint[] = Array.from({ length: 400 }, (_, id) => ({ id, x: random(), y: random(), z: random() }));
const index = new StarNeighbourhood(cloud);
for (const origin of [cloud[0], cloud[199], cloud[399]]) {
const brute = cloud
.filter((point) => point.id !== origin.id)
.map((point) => ({ id: point.id, distancePc: Math.hypot(point.x - origin.x, point.y - origin.y, point.z - origin.z) }))
.sort((a, b) => a.distancePc - b.distancePc);
expect(ids(index.nearest(origin.id, 5))).toEqual(ids(brute.slice(0, 5)));
expect(ids(index.within(origin.id, 20))).toEqual(ids(brute.filter((neighbour) => neighbour.distancePc <= 20)));
}
});
it('takes only the stars a filter accepts', () => {
const index = new StarNeighbourhood(line(10));
expect(ids(index.nearest(4, 2, (point) => point.id % 2 === 0))).toEqual([2, 6]);
});
it('puts the stars it is told to prefer first, and fills with the rest only when short', () => {
const index = new StarNeighbourhood(line(10));
const even = (point: StarPoint) => point.id % 2 === 0;
// Enough even stars: the odd ones next door, though nearer, do not get a look in.
expect(ids(index.nearestPreferring(4, 2, even))).toEqual([2, 6]);
// Not enough: every even star in reach, then the nearest of the others.
expect(ids(index.nearestPreferring(4, 6, even))).toEqual([2, 6, 0, 8, 3, 5]);
});
it('answers nothing for a star it has never heard of', () => {
const index = new StarNeighbourhood(line(3));
expect(index.nearest(99, 3)).toEqual([]);
expect(index.within(99, 10)).toEqual([]);
expect(index.point(99)).toBeUndefined();
});
it('asks for nothing and gets nothing', () => {
const index = new StarNeighbourhood(line(5));
expect(index.nearest(0, 0)).toEqual([]);
expect(index.within(0, 0)).toEqual([]);
});
it('finds every star inside a radius and none on the far side of it', () => {
const index = new StarNeighbourhood(line(20));
expect(ids(index.within(10, 2.5))).toEqual([9, 11, 8, 12]);
});
it('holds stars that share a position without losing either', () => {
// Real catalogue rows do this: Gl 65 A and B are one binary, two entries, one position.
const index = new StarNeighbourhood([
{ id: 1, x: 0, y: 0, z: 0 },
{ id: 2, x: 2.63, y: 0, z: 0 },
{ id: 3, x: 2.63, y: 0, z: 0 }
]);
expect(ids(index.nearest(1, 2)).sort()).toEqual([2, 3]);
});
/** 400 stars scattered 20 pc either side of the origin on every axis, so cells on both sides of zero. */
function cloud(): StarPoint[] {
let seed = 3;
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 40 - 20;
return Array.from({ length: 400 }, (_, id) => ({ id, x: random(), y: random(), z: random() }));
}
it('visits every star within a radius and no other', () => {
const points = cloud();
const origin = points[0];
const expected = points
.filter((point) => point.id !== origin.id && Math.hypot(point.x - origin.x, point.y - origin.y, point.z - origin.z) <= 7)
.map((point) => point.id)
.sort((a, b) => a - b);
const visited: number[] = [];
new StarNeighbourhood(points).forEachWithin(origin.id, 7, (neighbour) => visited.push(neighbour.id));
expect(visited.sort((a, b) => a - b)).toEqual(expected);
});
// The pair walk reads each cell's indices back out of its key; read wrong, it quietly drops
// pairs instead of failing.
it('walks every pair within a radius exactly once', () => {
const points = cloud();
let expected = 0;
for (let i = 0; i < points.length; i++) {
for (let j = i + 1; j < points.length; j++) {
if (Math.hypot(points[j].x - points[i].x, points[j].y - points[i].y, points[j].z - points[i].z) <= 5) {
expected++;
}
}
}
const walked = new Set<string>();
let visits = 0;
new StarNeighbourhood(points).forEachPairWithin(5, (a, b) => {
visits++;
walked.add(a.id < b.id ? `${a.id}-${b.id}` : `${b.id}-${a.id}`);
});
expect(visits).toBe(expected);
expect(walked.size).toBe(expected);
});
});
+283
View File
@@ -0,0 +1,283 @@
/**
* Which stars are near which, over the whole catalogue.
*
* Two questions are asked of the same catalogue and answered here once: "what are the k nearest
* stars to this one" (the neighbour labels shown from inside a system) and "which pairs lie
* within n parsecs of each other" (the jump-link graph). A linear scan answers the first
* acceptably — 68 000 distance tests, once, on entering a system — and the second not at all: a
* graph over a few thousand nodes is a few thousand scans, and the quadratic shows.
*
* So both run on a uniform grid keyed by cell coordinates. The catalogue is a dense blob around
* the Sun thinning out to 250 pc, which is exactly the distribution a uniform grid handles
* badly in the dense middle and well everywhere else — but the queries are all small radii in
* that same dense middle, where a cell holds a handful of stars, so the cost lands where the
* answers are. A KD-tree would be tighter and is not yet worth its code.
*/
/** A catalogued star reduced to what proximity needs: an id and a position in parsecs. */
export interface StarPoint {
readonly id: number;
readonly x: number;
readonly y: number;
readonly z: number;
}
/** A star found near another, with the separation that found it. */
export interface Neighbour {
readonly id: number;
readonly distancePc: number;
}
/**
* Cell edge in parsecs. Sized so a cell in the crowded inner catalogue holds a few dozen stars:
* small enough that a 5 pc query touches a handful of cells, large enough that a 250 pc
* catalogue does not allocate a map with a million keys.
*/
const DEFAULT_CELL_SIZE_PC = 5;
/** Grows the search a shell of cells at a time; the cap stops a query in empty space forever. */
const MAX_RING = 12;
/**
* Cells are keyed by one number packed from their three indices rather than by a string. A route
* search visits up to 125 cells for every star it expands, and building `"ix,iy,iz"` for each
* was half of what a route cost. Room for 65 536 cells either side of the Sun on every axis,
* 330 kpc at the default cell size, and the packed key stays inside a double's exact integers.
*/
const CELL_OFFSET = 65_536;
const CELL_SPAN = 131_072;
function cellKey(ix: number, iy: number, iz: number): number {
return ((ix + CELL_OFFSET) * CELL_SPAN + (iy + CELL_OFFSET)) * CELL_SPAN + (iz + CELL_OFFSET);
}
function cellIndices(key: number): [number, number, number] {
const iz = (key % CELL_SPAN) - CELL_OFFSET;
const rest = Math.floor(key / CELL_SPAN);
return [Math.floor(rest / CELL_SPAN) - CELL_OFFSET, (rest % CELL_SPAN) - CELL_OFFSET, iz];
}
export class StarNeighbourhood {
private readonly cells = new Map<number, number[]>();
private readonly points: readonly StarPoint[];
private readonly indexById = new Map<number, number>();
private readonly cellSizePc: number;
constructor(points: readonly StarPoint[], cellSizePc: number = DEFAULT_CELL_SIZE_PC) {
this.points = points;
this.cellSizePc = cellSizePc > 0 ? cellSizePc : DEFAULT_CELL_SIZE_PC;
points.forEach((point, index) => {
this.indexById.set(point.id, index);
const key = this.keyFor(point.x, point.y, point.z);
const cell = this.cells.get(key);
if (cell) {
cell.push(index);
} else {
this.cells.set(key, [index]);
}
});
}
/** Where the star this id names sits in the list the index was built from, or `undefined`. */
indexOf(id: number): number | undefined {
return this.indexById.get(id);
}
/** The star at this position in the list the index was built from. */
pointAt(index: number): StarPoint {
return this.points[index];
}
/** The star this id names, or `undefined` — the caller's id may not be in the catalogue. */
point(id: number): StarPoint | undefined {
const index = this.indexById.get(id);
return index === undefined ? undefined : this.points[index];
}
/**
* Like `nearest`, but the stars `prefer` accepts come first, and the rest only fill what is
* left. The preferred pass exhausts the search before the fill runs, so a preferred star is
* never outranked by an ordinary one that happens to be closer — that is the point of asking.
*/
nearestPreferring(id: number, count: number, prefer: (point: StarPoint) => boolean): Neighbour[] {
const preferred = this.nearest(id, count, prefer);
if (preferred.length >= count) {
return preferred;
}
const taken = new Set(preferred.map((neighbour) => neighbour.id));
return preferred.concat(this.nearest(id, count - preferred.length, (point) => !taken.has(point.id)));
}
/**
* The `count` stars nearest to `id`, nearest first, excluding the star itself.
*
* Searches outward a shell of cells at a time and stops only once the shell it just finished
* lies further away than the furthest result held — the ring that contains the kth star can
* still be beaten by a closer star in the next ring out, since a cell's near corner is nearer
* than its centre.
*/
nearest(id: number, count: number, filter?: (point: StarPoint) => boolean): Neighbour[] {
const origin = this.point(id);
if (!origin || count <= 0) {
return [];
}
const found: Neighbour[] = [];
const [ox, oy, oz] = this.cellFor(origin.x, origin.y, origin.z);
for (let ring = 0; ring <= MAX_RING; ring++) {
// Everything in this ring is at least this far away, so once the results already held are
// all closer than that, no further ring can improve them.
if (found.length >= count && (ring - 1) * this.cellSizePc > found[found.length - 1].distancePc) {
break;
}
for (const index of this.ringIndices(ox, oy, oz, ring)) {
const candidate = this.points[index];
if (candidate.id === id || (filter && !filter(candidate))) {
continue;
}
const distancePc = Math.hypot(candidate.x - origin.x, candidate.y - origin.y, candidate.z - origin.z);
if (found.length >= count && distancePc >= found[found.length - 1].distancePc) {
continue;
}
// Insertion sort into a list that is never longer than `count`: cheaper than sorting
// every candidate the rings turn up, of which there are far more than are kept.
const at = found.findIndex((other) => distancePc < other.distancePc);
found.splice(at === -1 ? found.length : at, 0, { id: candidate.id, distancePc });
if (found.length > count) {
found.pop();
}
}
}
return found;
}
/**
* Every star within `radiusPc` of `id`, nearest first, excluding the star itself. This is what
* a jump-link graph is built from: one call per node gives that node's edges.
*/
within(id: number, radiusPc: number): Neighbour[] {
const found: Neighbour[] = [];
this.forEachWithin(id, radiusPc, (neighbour, distancePc) => found.push({ id: neighbour.id, distancePc }));
found.sort((a, b) => a.distancePc - b.distancePc);
return found;
}
/**
* The same stars as `within`, handed over one at a time in no particular order. What a search
* that expands thousands of stars wants: it has no use for each star's neighbours sorted and
* collected into a list, which was the other half of what a route cost.
*
* A distance is compared as a distance, not as its square, here and in the pair walk: squaring
* a range can round it just under the square of the very hop it was read from, and then a
* range set to a reported distance would not admit that hop again.
*/
forEachWithin(id: number, radiusPc: number, visit: (neighbour: StarPoint, distancePc: number) => void): void {
const origin = this.point(id);
if (!origin || radiusPc <= 0) {
return;
}
const [ox, oy, oz] = this.cellFor(origin.x, origin.y, origin.z);
const reach = Math.ceil(radiusPc / this.cellSizePc);
for (let ix = ox - reach; ix <= ox + reach; ix++) {
for (let iy = oy - reach; iy <= oy + reach; iy++) {
for (let iz = oz - reach; iz <= oz + reach; iz++) {
const cell = this.cells.get(cellKey(ix, iy, iz));
if (!cell) {
continue;
}
for (const index of cell) {
const candidate = this.points[index];
if (candidate.id === id) {
continue;
}
const dx = candidate.x - origin.x;
const dy = candidate.y - origin.y;
const dz = candidate.z - origin.z;
const distancePc = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (distancePc <= radiusPc) {
visit(candidate, distancePc);
}
}
}
}
}
}
/**
* Visits every pair of stars within `radiusPc` of each other, once per pair.
*
* The same question `within` answers, asked of the whole catalogue at once — and a different
* shape of answer, because asking it star by star is asking it twice per pair and paying for a
* sorted list of each star's neighbours that the caller then throws away. Sixty-eight thousand
* of those took eight seconds; walking the grid once takes a fraction of it.
*
* Each cell is paired with itself and with the half of its surrounding cells that lie after it
* in the scan, which is what makes each pair come up exactly once.
*/
forEachPairWithin(radiusPc: number, visit: (a: StarPoint, b: StarPoint, distancePc: number) => void): void {
if (radiusPc <= 0) {
return;
}
const reach = Math.ceil(radiusPc / this.cellSizePc);
for (const [key, cell] of this.cells) {
const [ix, iy, iz] = cellIndices(key);
for (let dx = 0; dx <= reach; dx++) {
for (let dy = dx === 0 ? 0 : -reach; dy <= reach; dy++) {
for (let dz = dx === 0 && dy === 0 ? 0 : -reach; dz <= reach; dz++) {
const other = dx === 0 && dy === 0 && dz === 0 ? cell : this.cells.get(cellKey(ix + dx, iy + dy, iz + dz));
if (!other) {
continue;
}
const sameCell = other === cell;
for (let i = 0; i < cell.length; i++) {
const a = this.points[cell[i]];
// Within one cell, only the pairs after this one; across two, all of them — the
// other cell is only ever visited from this side.
for (let j = sameCell ? i + 1 : 0; j < other.length; j++) {
const b = this.points[other[j]];
const dxp = b.x - a.x;
const dyp = b.y - a.y;
const dzp = b.z - a.z;
const distancePc = Math.sqrt(dxp * dxp + dyp * dyp + dzp * dzp);
if (distancePc <= radiusPc) {
visit(a, b, distancePc);
}
}
}
}
}
}
}
}
private keyFor(x: number, y: number, z: number): number {
const [ix, iy, iz] = this.cellFor(x, y, z);
return cellKey(ix, iy, iz);
}
private cellFor(x: number, y: number, z: number): [number, number, number] {
return [Math.floor(x / this.cellSizePc), Math.floor(y / this.cellSizePc), Math.floor(z / this.cellSizePc)];
}
/** Indices in the hollow shell of cells exactly `ring` cells out from the centre one. */
private *ringIndices(ox: number, oy: number, oz: number, ring: number): Generator<number> {
for (let ix = ox - ring; ix <= ox + ring; ix++) {
for (let iy = oy - ring; iy <= oy + ring; iy++) {
for (let iz = oz - ring; iz <= oz + ring; iz++) {
// Only the shell: everything inside it was searched by a previous, smaller ring.
const onShell = Math.abs(ix - ox) === ring || Math.abs(iy - oy) === ring || Math.abs(iz - oz) === ring;
if (!onShell) {
continue;
}
yield* this.cells.get(cellKey(ix, iy, iz)) ?? [];
}
}
}
}
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import { distanceRings, formatRoundLength, roundLengthAtMost, scaleBar } from './scale-bar';
describe('roundLengthAtMost', () => {
it('rounds down to 1, 2 or 5 times a power of ten', () => {
expect(roundLengthAtMost(51)).toBe(50);
expect(roundLengthAtMost(3.3)).toBe(2);
expect(roundLengthAtMost(0.7)).toBe(0.5);
expect(roundLengthAtMost(1999)).toBe(1000);
});
it('keeps a length that is already round, including at a decade', () => {
expect(roundLengthAtMost(1000)).toBe(1000);
expect(roundLengthAtMost(100)).toBe(100);
expect(roundLengthAtMost(5)).toBe(5);
expect(roundLengthAtMost(0.2)).toBe(0.2);
});
it('has no length for nothing', () => {
for (const value of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(roundLengthAtMost(value)).toBeNull();
}
});
});
describe('distanceRings', () => {
// The opening view sits about 307 pc from the Sun: the rings the map always had, with the
// survey edge at the fifth, and on out past the camera for the stars now drawn beyond it.
it('reaches past the camera from the opening view', () => {
expect(distanceRings(0, 307, 5, 250)).toEqual([50, 100, 150, 200, 250, 300, 350]);
});
it('closes in with the camera', () => {
expect(distanceRings(0, 20, 5, 250)).toEqual([2, 4, 6, 8, 10, 12, 14, 16, 18, 20]);
expect(distanceRings(0, 1, 5, 250)).toEqual([0.2, 0.4, 0.6, 0.8, 1]);
});
// Near Mirfak the camera is 155 pc out; rounding the step down to 20 pc must not leave the
// rings stopping at 100.
it('covers the whole distance whatever the rounding', () => {
expect(distanceRings(0, 155, 5, 250)).toEqual([20, 40, 60, 80, 100, 120, 140, 160]);
});
// A star 190 pc out seen from 20 pc away: the frame is a band about 19 pc either side of it and
// the Sun is nowhere in it. Sized to the 210 pc it reaches, the step would be 20 pc and the
// nearest rings — 180 and 200 — would both miss the frame.
it('spaces the rings for a frame that does not hold the Sun', () => {
const radii = distanceRings(171, 210, 5, 250);
expect(radii).toEqual([170, 175, 180, 185, 190, 195, 200, 205, 210]);
expect(radii.some((radius) => Math.abs(radius - 190) < 19)).toBe(true);
});
it('marks the callout among rings the step does not land on', () => {
expect(distanceRings(0, 1000, 5, 250)).toEqual([200, 250, 400, 600, 800, 1000]);
});
it('leaves the callout out when it is past the last ring, or behind the first', () => {
expect(distanceRings(0, 100, 5, 250)).toEqual([20, 40, 60, 80, 100]);
// Short of the survey edge by less than one step is not the rule — the last ring is: 245 pc
// overshoots to 260 and gets it, 235 pc stops at 240 and does not, on the same 20 pc step.
expect(distanceRings(0, 245, 5, 250)).toContain(250);
expect(distanceRings(0, 235, 5, 250)).not.toContain(250);
expect(distanceRings(400, 440, 5, 250)).toEqual([400, 405, 410, 415, 420, 425, 430, 435, 440]);
});
it('draws no rings for a camera with no distance', () => {
expect(distanceRings(0, 0, 5, 250)).toEqual([]);
});
});
describe('scaleBar', () => {
it('picks the longest round length that fits, and the width it spans', () => {
// A tenth of a parsec a pixel and 120 px of room: 12 pc would fit, and the round length
// under it is 10 pc, which spans 100 px.
expect(scaleBar(0.1, 120, 'pc')).toEqual({ label: '10 pc', widthPx: 100 });
});
it('draws nothing for a view with no extent', () => {
expect(scaleBar(0, 120, 'pc')).toBeNull();
});
});
describe('formatRoundLength', () => {
it('reads without trailing zeros, in kiloparsecs past a thousand', () => {
expect(formatRoundLength(2000, 'pc')).toBe('2 kpc');
expect(formatRoundLength(500, 'pc')).toBe('500 pc');
expect(formatRoundLength(0.2, 'pc')).toBe('0.2 pc');
expect(formatRoundLength(0.05, 'AU')).toBe('0.05 AU');
});
// Ring radii are multiples of a round step rather than round themselves; a one-digit format
// printed the 250 pc survey edge as "300 pc".
it('keeps every digit of a ring radius', () => {
expect(formatRoundLength(250, 'pc')).toBe('250 pc');
expect(formatRoundLength(150, 'pc')).toBe('150 pc');
expect(formatRoundLength(2500, 'pc')).toBe('2.5 kpc');
});
});
+86
View File
@@ -0,0 +1,86 @@
/**
* The round lengths a map is read against: its scale bar, and the spacing of its distance rings.
*
* Round means 1, 2 or 5 times a power of ten — the only lengths a reader can add up at a glance,
* which is why every printed map's scale bar uses them.
*/
/** The largest 1, 2 or 5 × a power of ten that is at most `value`, or `null` for no length at all. */
export function roundLengthAtMost(value: number): number | null {
if (!Number.isFinite(value) || value <= 0) {
return null;
}
const power = 10 ** Math.floor(Math.log10(value));
const mantissa = value / power;
return (mantissa >= 5 ? 5 : mantissa >= 2 ? 2 : 1) * power;
}
/**
* Rings across the span from `nearest` to `reach`, at a round step of about a `count`th of it,
* plus `callout` where it falls between the first ring and the last: the grid's own radii are
* round, and the one radius that means something in its own right is marked whether the step lands
* on it or not. A frame that stops short of it gets it only when the last ring — the first multiple
* of `step` at or past `reach` — is past it: reach 245 with a 20 pc step gets it, reach 235 does
* not, since its last ring is 240.
*
* Two numbers rather than one because these rings are centred on a fixed point — the Sun — and a
* frame need not be. Looking at something 200 pc out from 20 pc away, what is on screen is a band
* 200 pc wide at its narrowest and nowhere near the Sun; a step sized to the whole 220 puts every
* ring off the frame. The span is what the frame covers, so the step is what it can resolve.
*
* Rounding the step down, over a span that need not start at the Sun, makes for `count` to
* `ceil(2.5 × count) + 2` rings — `ceil(reach / step) - floor(nearest / step) + 1` — and the
* callout can add one: 5 to 16 for a count of 5.
*/
export function distanceRings(nearest: number, reach: number, count: number, callout: number): number[] {
const step = roundLengthAtMost((reach - nearest) / count);
if (step === null) {
return [];
}
// The ring just inside the near edge of the span, so the band is crossed rather than started at.
const first = Math.max(1, Math.floor(nearest / step));
const last = Math.ceil(reach / step);
// `toPrecision` clears the binary noise of stepping by a tenth: 0.1 × 3 is 0.30000000000000004.
const radii = Array.from({ length: last - first + 1 }, (_, index) => Number((step * (first + index)).toPrecision(12)));
if (callout > radii[0] && callout < radii[radii.length - 1] && !radii.includes(callout)) {
radii.push(callout);
radii.sort((a, b) => a - b);
}
return radii;
}
export type LengthUnit = 'pc' | 'AU';
/** A round length, and how many pixels it spans at the current zoom. */
export interface ScaleBar {
readonly label: string;
readonly widthPx: number;
}
/**
* The longest round length that fits in `maxWidthPx` when one pixel spans `unitsPerPx`.
*
* Under a perspective camera a pixel spans a different length at every depth, so the scene
* measures `unitsPerPx` at the point the view is centred on, which is where the map is being
* read. Under the plan view it is exact everywhere.
*/
export function scaleBar(unitsPerPx: number, maxWidthPx: number, unit: LengthUnit): ScaleBar | null {
const length = roundLengthAtMost(unitsPerPx * maxWidthPx);
if (length === null) {
return null;
}
return { label: formatRoundLength(length, unit), widthPx: length / unitsPerPx };
}
/** A scale or ring length, in kiloparsecs past a thousand parsecs. */
export function formatRoundLength(length: number, unit: LengthUnit): string {
if (unit === 'pc' && length >= 1000) {
return `${digitsOf(length / 1000)} kpc`;
}
return `${digitsOf(length)} ${unit}`;
}
/** `0.05`, `2`, `150`, never `2.00`: these lengths have no digits past the ones that carry them. */
function digitsOf(value: number): string {
return String(Number(value.toPrecision(3)));
}
+113 -5
View File
@@ -1,7 +1,7 @@
/** /**
* Osculating Keplerian orbital elements at a reference epoch. Positions are derived * Keplerian orbital elements at a reference epoch. Positions are derived client-side by
* client-side by propagating these elements forward/backward from `epochJd` (see * propagating these elements forward/backward from `epochJd` (see `shared/astro/kepler.ts`),
* `shared/astro/kepler.ts`), rather than fetching per-frame positions. * rather than fetching per-frame positions.
*/ */
export interface OrbitalElements { export interface OrbitalElements {
semiMajorAxisAu: number; semiMajorAxisAu: number;
@@ -14,19 +14,127 @@ export interface OrbitalElements {
} }
/** /**
* A solar-system planet, moon, or dwarf planet, sourced from JPL Horizons/SSD orbital * How a body's mean elements move away from their epoch, per day.
* elements. `systemStarId` links back to the HYG star index (the Sun, see `SUN_STAR_ID`). *
* Mean elements rather than one osculating set, because the map's clock runs decades in minutes.
* An osculating orbit is exact at its instant and drifts from then on: fed to Kepler with a mass
* ratio, the Moon's went round in 27.70 days instead of 27.32 and was 66 degrees out after a year.
* A mean set carries its own measured motion, and the slow turning of its node and periapsis, so
* it holds for as long as its source was fit over.
*/
export interface MeanElementRates {
/**
* How fast the body goes round in space, in degrees per day: the rate of its mean longitude.
* 360 over this is its sidereal period.
*/
meanMotionDegPerDay: number;
longitudeOfAscendingNodeDegPerDay: number;
argumentOfPeriapsisDegPerDay: number;
semiMajorAxisAuPerDay?: number;
eccentricityPerDay?: number;
inclinationDegPerDay?: number;
/**
* Standish's extra terms in the mean anomaly of Jupiter and beyond, `b T² + c cos(f T) +
* s sin(f T)` degrees, with T in Julian centuries from the epoch and f in degrees per century:
* the great-inequality wobble his 3000 BC to AD 3000 fit needs on top of its linear rates.
*/
meanAnomalyTerms?: { b: number; c: number; s: number; f: number };
}
/**
* A solar-system planet, moon, or dwarf planet: JPL mean orbital elements, and JPL Horizons
* physical data. `systemStarId` links back to the HYG star index (the Sun, see `SUN_STAR_ID`).
*/ */
export interface BodyRecord { export interface BodyRecord {
id: string; id: string;
systemStarId: number; systemStarId: number;
name: string; name: string;
kind: 'planet' | 'moon' | 'dwarf'; kind: 'planet' | 'moon' | 'dwarf';
/** Mean radius: for a triaxial body, the radius of the sphere of its volume, which is how it is drawn. */
radiusKm: number; radiusKm: number;
/**
* A triaxial body's three semi-axes, in km, largest first, where its shape is too far from a
* sphere for one radius to say it: Haumea's 1161 x 852 x 513 (Ortiz et al. 2017), whose mean
* radius is 798.
*/
semiAxesKm?: readonly [number, number, number];
/** Mean elements at `orbit.epochJd`, moving at `rates`. */
orbit: OrbitalElements; orbit: OrbitalElements;
rates: MeanElementRates;
/**
* The pole of the plane a moon's elements are measured against, where that is its local
* Laplace plane or, for Uranus's and Pluto's moons, the planet's equator: right ascension and
* declination in the ICRF. The node is then counted from where that plane crosses the ICRF
* equator. Absent means the J2000 ecliptic, as for the planets and the Moon.
*/
laplacePole?: { raDeg: number; decDeg: number };
/** Where the elements come from and the span they hold over, as the card prints it. */
orbitSource: string;
/**
* The eccentricity the card prints, where it is not the orbit's own: Hyperion's row in the table
* its orbit is drawn from gives 0.0232, under a quarter of the 0.105 JPL's current table (SAT441)
* and Horizons (0.074 to 0.132 from 1980 to 2100) give. The older row still places Hyperion
* nearer where Horizons has it than the same row with 0.105 does, so the orbit keeps it.
*/
measuredEccentricity?: number;
/** /**
* For `kind: 'moon'`, the `id` of the planet it orbits — its `orbit` is expressed * For `kind: 'moon'`, the `id` of the planet it orbits — its `orbit` is expressed
* relative to that planet, not heliocentrically. Undefined for planets/dwarfs. * relative to that planet, not heliocentrically. Undefined for planets/dwarfs.
*/ */
parentBodyId?: string; parentBodyId?: string;
/**
* For a moon heavy enough that it and its planet go round a point outside the planet — Charon,
* an eighth of Pluto's mass, puts it 2 100 km from Pluto's centre, 900 km above its surface —
* the moon's mass over the planet's, from the GMs on their Horizons pages. The planet's own
* elements then place that barycentre, as Standish's "Pluto" does, and both bodies are drawn
* going round it. Absent for every other moon.
*/
massRatio?: number;
/**
* 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.
*
* For a locked moon the period is its orbit's, from the mean motion that carries it round. Where
* the source states none, or one a later measurement overturns, it is the one the body's ETL spec
* carries: Nereid's K2 light curve, Eris's lock to Dysnomia. Absent only for Hyperion, which
* tumbles — the view leaves it still rather than spinning it at an invented rate.
*/
rotationPeriodHours?: number;
obliquityDeg?: number;
/**
* Where the body's pole points and which way its prime meridian faces at any date, from the IAU
* WGCCRE 2015 report (Archinal et al. 2018) as NAIF's `pck00011.tpc` carries it, but that a locked
* moon's W, and its pole's terms that turn within 5 per cent of a multiple of its node's rate, turn
* at its drawn orbit's rates and Iapetus's pole goes round with its orbit's, so they keep their
* faces to their planets, and their poles round their orbits', over the clock's AD 1 to 3000 (see
* `lockedToOrbit` in the ETL). The Moon's and Phobos's, whose W has a quadratic, are the IAU's
* whole, and so are the terms Ariel's, Umbriel's, Titania's and Oberon's poles go round on, which
* turn at none of their nodes' multiples. Where present it alone sets how the body is drawn, and
* the ETL checks the period and obliquity above against it. Absent where the report gives none: Hyperion tumbles, and Nereid,
* Eris, Haumea and Makemake have no model.
*/
rotationalElements?: RotationalElements;
}
/**
* The IAU's rotational elements for one body: polynomials in time, plus periodic terms.
*
* The pole's right ascension and declination are in degrees in the ICRF, `[c0, c1, c2]` for
* `c0 + c1 T + c2 T²`, T in Julian centuries from J2000.0 TDB. The prime meridian W is the angle
* along the body's equator, anticlockwise seen from above that pole, from where the equator rises
* through the ICRF equator to the body's longitude 0, `c0 + c1 d + c2 d²` with d in days. A
* negative rate turns the body clockwise about the pole the IAU names: Venus, Uranus and its
* moons, Triton.
*/
export interface RotationalElements {
poleRaDeg: number[];
poleDecDeg: number[];
primeMeridianDeg: number[];
/**
* Each adds `ra sin θ` to the right ascension, `dec cos θ` to the declination and `pm sin θ` to
* W, θ being `angleDeg[0] + angleDeg[1] T + angleDeg[2] T²`. The smallest are left out; see
* `parsePckRotationalElements`.
*/
terms?: Array<{ angleDeg: number[]; ra: number; dec: number; pm: number }>;
} }
+13 -6
View File
@@ -12,6 +12,11 @@ export interface ExoplanetRecord {
radiusEarth?: number; radiusEarth?: number;
massEarth?: number; massEarth?: number;
discoveryYear?: number; discoveryYear?: number;
/**
* True where the archive flags the planet as detected by imaging (`ima_flag`): photographed as a
* point of light beside its star, as HR 8799's four planets were. Absent for every other planet.
*/
imaged?: true;
/** /**
* Measured orbital period in days (`pl_orbper`). Together with the semi-major axis this * Measured orbital period in days (`pl_orbper`). Together with the semi-major axis this
* pins the host star's gravitational parameter exactly, so the planet can be propagated at * pins the host star's gravitational parameter exactly, so the planet can be propagated at
@@ -21,17 +26,19 @@ export interface ExoplanetRecord {
/** Host star mass in solar masses (`st_mass`); the fallback when no period is published. */ /** Host star mass in solar masses (`st_mass`); the fallback when no period is published. */
hostStarMassSolar?: number; hostStarMassSolar?: number;
/** /**
* The host star's own published position (`ra`, `dec`, `sy_dist`) — the coordinates the * The host star's own published astrometry (`ra`, `dec`, `sy_dist`, `sy_pmra`, `sy_pmdec`) —
* cross-reference above is resolved from. * everything the cross-reference above was resolved from.
* *
* Kept rather than consumed and discarded. `hostStarId` is the *result* of a match against * Kept rather than consumed and discarded. `hostStarId` is the *result* of a match against
* whatever star catalogue was loaded at the time, so widening that catalogue ought to rescue * whatever star catalogue was loaded at the time; keeping the inputs makes auditing or
* some of the 4347 hosts that currently resolve to nothing — but with only the result stored, * redoing that match a local operation instead of a TAP query against an archive that is not
* redoing the match meant re-downloading the archive. These three numbers make it a local * always reachable — it is how the matcher's tolerances were measured. See
* operation. See `rematchHostStars`. * `resolveHostStarId`.
*/ */
hostRaDeg?: number; hostRaDeg?: number;
hostDecDeg?: number; hostDecDeg?: number;
hostDistancePc?: number; hostDistancePc?: number;
hostPmRaMasPerYear?: number;
hostPmDecMasPerYear?: number;
orbit: Partial<OrbitalElements>; orbit: Partial<OrbitalElements>;
} }
+15 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog } from './star-catalog'; import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog, isDesignation } from './star-catalog';
import { StarRecord } from './star.model'; import { StarRecord } from './star.model';
const STARS: StarRecord[] = [ const STARS: StarRecord[] = [
@@ -131,4 +131,18 @@ describe('star catalogue provenance and derived names', () => {
expect(named.index.names).toEqual(['Some Proper Name']); expect(named.index.names).toEqual(['Some Proper Name']);
expect(decodeStarCatalog(named.index, named.positions, named.meta)[0].name).toBe('Some Proper Name'); expect(decodeStarCatalog(named.index, named.positions, named.meta)[0].name).toBe('Some Proper Name');
}); });
it('tells a name somebody gave from the designation a survey generates', () => {
expect(isDesignation(decoded[0])).toBe(false);
expect(isDesignation(decoded[1])).toBe(true);
// The real catalogue names Gaia stars by the survey's nineteen-digit source id, which no
// 32-bit row id can equal — so it is the prefix that decides, not a round trip through the id.
expect(isDesignation({ ...MIXED[1], name: 'Gaia DR3 5853498713190525696' })).toBe(true);
expect(isDesignation({ ...MIXED[1], name: 'Proxima Centauri' })).toBe(false);
// HYG's own last resort, once every designation it knows has come up empty. Upper case,
// whatever case the source id is in.
expect(isDesignation({ ...MIXED[0], name: 'HYG 5' })).toBe(true);
expect(isDesignation({ ...MIXED[0], source: undefined, name: 'HYG 5' })).toBe(true);
expect(isDesignation({ ...MIXED[0], name: 'HD 48915' })).toBe(false);
});
}); });
+18 -3
View File
@@ -66,11 +66,14 @@ export interface StarCatalogIndex {
} }
/** /**
* How a source names a star that has no name of its own. `Gaia DR3 <id>` for Gaia; HYG's own * How a source names a star that has no name of its own. `Gaia DR3 <id>` for Gaia; `HYG <id>`
* fallbacks already produce real designations, so it never needs this. * for HYG, whose ETL reaches for that only after a proper name, Bayer, Flamsteed, HD, Gliese and
* HIP have all come up empty (`tools/etl/fetchStars.ts`) — none in the current catalogue, but
* the path is there, and a name made that way is no more a name than Gaia's.
*/ */
const DESIGNATION_PREFIXES: Readonly<Record<string, string>> = { const DESIGNATION_PREFIXES: Readonly<Record<string, string>> = {
gaia: 'Gaia DR3' gaia: 'Gaia DR3',
hyg: 'HYG'
}; };
interface StarMetaColumns { interface StarMetaColumns {
@@ -161,6 +164,18 @@ function designationFor(prefix: string | undefined, id: number): string | undefi
return prefix === undefined ? undefined : `${prefix} ${id}`; return prefix === undefined ? undefined : `${prefix} ${id}`;
} }
/**
* Whether a star's name is only the designation its source generates, rather than anything
* somebody called it. Judged by the prefix alone: the number after it is the survey's own id —
* nineteen digits for Gaia — which the 32-bit row id `designationFor` prints cannot hold, so a
* round-trip through the id would call every one of those stars named.
*/
export function isDesignation(star: StarRecord): boolean {
// No source at all is a single-catalogue build, whose fallback is HYG's — see `decodeStarCatalog`.
const prefix = star.source === undefined ? DESIGNATION_PREFIXES['hyg'] : (DESIGNATION_PREFIXES[star.source] ?? star.source);
return star.name.startsWith(`${prefix} `);
}
/** Rebuilds the star records the app works with from the three loaded assets. */ /** Rebuilds the star records the app works with from the three loaded assets. */
export function decodeStarCatalog(index: StarCatalogIndex, positions: Float32Array, meta: ArrayBuffer): StarRecord[] { export function decodeStarCatalog(index: StarCatalogIndex, positions: Float32Array, meta: ArrayBuffer): StarRecord[] {
const columns = metaColumns(meta, index.count); const columns = metaColumns(meta, index.count);
@@ -0,0 +1,99 @@
import * as THREE from 'three/webgpu';
import { describe, expect, it } from 'vitest';
import { tdbFromUtc } from '../astro/constants';
import { eclipticToEquatorial } from '../astro/coordinates';
import { meanElementsAt, positionAtEpoch } from '../astro/kepler';
import { BodyRecord } from '../models/body.model';
import { bodyOrientation, bodyPageView } from './body-orientation';
// Earth (the Earth-Moon barycentre's mean elements) and the Moon as bodies.json carries them.
const EARTH: BodyRecord = {
id: 'earth', systemStarId: 0, name: 'Earth', kind: 'planet', radiusKm: 6371, orbitSource: 'test',
orbit: {semiMajorAxisAu: 1.00000018, eccentricity: 0.01673163, inclinationDeg: -0.00054346, longitudeOfAscendingNodeDeg: -5.11260389, argumentOfPeriapsisDeg: 108.04266274, meanAnomalyAtEpochDeg: -2.4631431299999917, epochJd: 2451545},
rates: {meanMotionDegPerDay: 0.9856091187759068, longitudeOfAscendingNodeDegPerDay: -0.000006604751813826146, argumentOfPeriapsisDegPerDay: 0.000015309819575633124, semiMajorAxisAuPerDay: -8.213552361396303e-13, eccentricityPerDay: -1.002327173169062e-9, inclinationDegPerDay: -3.6609938398357287e-7},
rotationalElements: {poleRaDeg: [0, -0.641, 0], poleDecDeg: [90, -0.557, 0], primeMeridianDeg: [190.147, 360.9856235, 0]}
};
const MOON: BodyRecord = {
id: 'moon', systemStarId: 0, name: 'Moon', kind: 'moon', radiusKm: 1737.4, orbitSource: 'test', parentBodyId: 'earth',
orbit: {semiMajorAxisAu: 0.0025695552897999907, eccentricity: 0.0554, inclinationDeg: 5.16, longitudeOfAscendingNodeDeg: 125.08, argumentOfPeriapsisDeg: 318.15, meanAnomalyAtEpochDeg: 135.27, epochJd: 2451545},
rates: {meanMotionDegPerDay: 13.176358, longitudeOfAscendingNodeDegPerDay: -0.052990660396105185, argumentOfPeriapsisDegPerDay: 0.164353223839846},
rotationalElements: {poleRaDeg: [269.9949, 0.0031, 0], poleDecDeg: [66.5392, 0.013, 0], primeMeridianDeg: [38.3213, 13.17635815, -1.4e-12], terms: [{angleDeg: [125.045, -1935.5364525], ra: -3.8787, dec: 1.5419, pm: 3.561}, {angleDeg: [250.089, -3871.072905], ra: -0.1204, dec: 0.0239, pm: 0.1208}, {angleDeg: [260.008, 475263.3328725], ra: 0.07, dec: -0.0278, pm: -0.0642}, {angleDeg: [176.625, 487269.629985], ra: -0.0172, dec: 0.0068, pm: 0.0158}, {angleDeg: [357.529, 35999.0509575], ra: 0, dec: 0, pm: 0.0252}]}
};
const BODIES = [EARTH, MOON];
const JUNE_1_2025_NOON_UTC = 2460828.0;
/** The page's light, at (4, 3, 5): 38.7 degrees round from the camera's side. */
const SUN_AZIMUTH = Math.atan2(4, 5);
/** The point of the page's sphere, as east longitude and latitude on its map, that faces the Sun. */
function subSolarPoint(body: BodyRecord, jdUtc: number): { eastDeg: number; latDeg: number } {
const sphere = new THREE.Mesh(new THREE.SphereGeometry(1, 64, 32));
const sun = new THREE.Vector3();
expect(bodyPageView(body, BODIES, jdUtc, SUN_AZIMUTH, sphere.quaternion, sun)).toBe(true);
sphere.updateMatrixWorld();
const hit = new THREE.Raycaster(sun.clone().multiplyScalar(4), sun.clone().negate()).intersectObject(sphere)[0];
return { eastDeg: (hit.uv!.x - 0.5) * 360, latDeg: (hit.uv!.y - 0.5) * 180 };
}
describe('bodyPageView', () => {
it('lights the same face of Earth on its page: within 4 degrees of Greenwich at noon UTC, and where Horizons has it', () => {
expect(Math.abs(subSolarPoint(EARTH, JUNE_1_2025_NOON_UTC).eastDeg)).toBeLessThan(4);
// Horizons' sub-solar point from the Sun, 1.5795 E and 22.2604 N, is Earth as it was 8.43
// minutes before, when the light arriving then left the Sun; its latitude is geodetic, on the
// flattened Earth, where the sphere's is geocentric: 0.14 degrees apart at this latitude.
const horizons = subSolarPoint(EARTH, JUNE_1_2025_NOON_UTC - 8.43351424 / 1440);
const geodetic = (Math.atan(Math.tan((horizons.latDeg * Math.PI) / 180) / (1 - 1 / 298.257) ** 2) * 180) / Math.PI;
expect(Math.abs(horizons.eastDeg - 1.579501)).toBeLessThan(0.1);
expect(Math.abs(geodetic - 22.260426)).toBeLessThan(0.05);
});
it('lights Earth’s face where Horizons does at the far end of the clock too: AD 1000 and AD 1', () => {
// Horizons' sub-solar longitude from the Sun (observer quantity 14, TIME_TYPE=UT), Earth taken
// one light-time back: 1.0510 E on JD 2086455 and 1.5606 E on JD 1721600. The IAU's W, taken at
// UT + 69.184 s as it was, drew them 2.3 and 4.6 degrees west of that (2.0 and 4.3 at UT itself).
for (const [jdUt, lightMinutes, eastDeg] of [[2086455, 8.45437443, 1.05101], [1721600, 8.45020842, 1.560644]]) {
expect(Math.abs(subSolarPoint(EARTH, jdUt - lightMinutes / 1440).eastDeg - eastDeg)).toBeLessThan(0.15);
}
});
it('takes a moon’s Sun from where it and its planet are: the Moon’s sub-solar point is Horizons’', () => {
const moon = subSolarPoint(MOON, JUNE_1_2025_NOON_UTC);
// 116.2859 E and 1.5030 N, seen from Earth's centre.
expect(Math.abs(moon.eastDeg - 116.285934)).toBeLessThan(0.1);
expect(Math.abs(moon.latDeg - 1.503004)).toBeLessThan(0.05);
});
it('takes the Sun where it stands at the same TDB instant the body is turned for, and Earth turned as the system view turns it', () => {
// Earth's own sphere, turned as the system view turns it (by UT, see `bodyOrientation`), and
// the Sun seen from Earth's mean place at the clock's date taken to TDB: the page must light that
// same point of its map, today and at AD 1000, when TT was 1 574 s past UT.
for (const jdUt of [JUNE_1_2025_NOON_UTC, 2086307.5]) {
const planet = new THREE.Quaternion();
const sun = new THREE.Vector3();
bodyPageView(EARTH, BODIES, jdUt, SUN_AZIMUTH, planet, sun);
const place = eclipticToEquatorial(positionAtEpoch(meanElementsAt(EARTH.orbit, EARTH.rates, tdbFromUtc(jdUt))));
const expected = new THREE.Vector3(-place.x, -place.y, -place.z).normalize().applyQuaternion(bodyOrientation(EARTH.rotationalElements!, jdUt, undefined, true).invert());
expect(sun.clone().applyQuaternion(planet.clone().invert()).angleTo(expected)).toBeLessThan(1e-9);
}
});
it('keeps the pole up and the Sun where the page’s light stands, turning the body under it', () => {
const planet = new THREE.Quaternion();
const sun = new THREE.Vector3();
for (const hours of [0, 6, 12]) {
bodyPageView(EARTH, BODIES, JUNE_1_2025_NOON_UTC + hours / 24, SUN_AZIMUTH, planet, sun);
expect(new THREE.Vector3(0, 1, 0).applyQuaternion(planet).angleTo(new THREE.Vector3(0, 1, 0))).toBeLessThan(1e-9);
expect(Math.atan2(sun.x, sun.z)).toBeCloseTo(SUN_AZIMUTH, 9);
}
});
it('leaves a body with no elements to the page, as it was', () => {
const planet = new THREE.Quaternion(0.1, 0.2, 0.3, 0.9).normalize();
const before = planet.clone();
const sun = new THREE.Vector3(4, 3, 5);
expect(bodyPageView({ ...EARTH, rotationalElements: undefined }, BODIES, JUNE_1_2025_NOON_UTC, SUN_AZIMUTH, planet, sun)).toBe(false);
expect(planet.equals(before)).toBe(true);
expect(sun.toArray()).toEqual([4, 3, 5]);
});
});
@@ -0,0 +1,115 @@
import * as THREE from 'three/webgpu';
import { tdbFromUtc } from '../astro/constants';
import { CartesianCoordinates, eclipticToEquatorial, laplacePlaneToEquatorial } from '../astro/coordinates';
import { meanElementsAt, positionAtEpoch } from '../astro/kepler';
import { orientationAt } from '../astro/rotational-elements';
import { BodyRecord, RotationalElements } from '../models/body.model';
const DEG_TO_RAD = Math.PI / 180;
const Y_AXIS = new THREE.Vector3(0, 1, 0);
const Z_AXIS = new THREE.Vector3(0, 0, 1);
/**
* How a surface map sits on a sphere, settled once for every map the app wraps.
*
* `THREE.SphereGeometry` is built round +Y and runs its u coordinate eastward, anticlockwise seen
* from +Y, from a seam on -X: u = 0.5 faces +X and u = 0.75 faces -Z. Every photograph in
* `texture-catalog.ts` is an equirectangular map centred on longitude 0 with east to the right —
* Greenwich is in the middle of Earth's; on Mars's, Olympus Mons (226.2 E, which is -133.8) sits a
* little over a third of the width left of centre; on the Moon's, Mare Crisium (59 E) is right of
* centre and Mare Orientale (95 W) left of it; on Mercury's, the rayed crater Kuiper (31.5 W, 11 S)
* is just left of centre and below the equator; on Venus's, Maxwell Montes (65.2 N, 3.3 E) is the
* brightest spot, high and just right of centre — Solar System Scope ships that map turned half
* round, south up, and it is kept turned back. A map labelled in west longitude, as most planets'
* are, is still drawn with east to the right, as any map of a sphere seen from outside is; only its
* numbers run the other way. So longitude 0 is +X and 90 E is -Z, and a quarter turn about X
* carries that onto the IAU's body-fixed frame: pole +Z, prime meridian +X, 90 E +Y.
*
* The derived surfaces have no meridian of their own, and take the same convention.
*/
export const MAP_TO_BODY = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2);
const scratchMatrix = new THREE.Matrix4();
const scratchAxes = [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()];
const scratchTurn = new THREE.Quaternion();
/**
* Sets `target` to the rotation carrying a frame whose +Z is `pole` and whose +X is where its
* equator rises through the ICRF equator into the ICRF: the frame the IAU counts W in, and the
* one JPL refers a moon's Laplace plane to — so both go through {@link laplacePlaneToEquatorial}.
*/
export function poleFrame(pole: { raDeg: number; decDeg: number }, target = new THREE.Quaternion()): THREE.Quaternion {
const [x, y, z] = scratchAxes.map((axis, index) => {
const turned = laplacePlaneToEquatorial({ x: index === 0 ? 1 : 0, y: index === 1 ? 1 : 0, z: index === 2 ? 1 : 0 }, pole);
return axis.set(turned.x, turned.y, turned.z);
});
return target.setFromRotationMatrix(scratchMatrix.makeBasis(x, y, z));
}
/**
* Sets `target` to the rotation carrying a sphere, wrapped in its map as `SphereGeometry` wraps it,
* into the ICRF at the map's own clock: the map onto the body's frame, turned by W about the pole,
* and on to where the pole points.
*
* The clock is UT and the IAU's elements run on TDB, 69.184 s ahead today and 1 574 s at AD 1000;
* in 69 s Earth turns 0.29 degrees, Jupiter 0.70 and Phobos 0.90, so the date is taken to TDB here
* (see `tdbFromUtc`). Earth, `followsUt`, is the one exception: its turning is what UT counts, so
* it is turned by the IERS Earth Rotation Angle at the clock's date (IERS Conventions 2010, eq.
* 5.15), counted from the node its W starts at, 90 degrees past its pole's right ascension. The
* IAU's W for Earth, fitted to today, runs 6.3e-6 degrees a day slow of that once its pole's drift
* is counted: taken at UT + 69.184 s, as it was, it left Earth's lit face 2.3 degrees off Horizons at
* AD 1000 and 4.5 to 4.6 over AD 1 (2.0, and 4.2 to 4.3, at UT itself). Taken at TDB, it would
* have turned ΔT further, 44 degrees at AD 1.
*/
export function bodyOrientation(elements: RotationalElements, jdUtc: number, target = new THREE.Quaternion(), followsUt = false): THREE.Quaternion {
const { poleRaDeg, poleDecDeg, primeMeridianDeg } = orientationAt(elements, tdbFromUtc(jdUtc));
const turnDeg = followsUt ? 360 * (0.779057273264 + 1.00273781191135448 * (jdUtc - 2451545)) - 90 - poleRaDeg : primeMeridianDeg;
return poleFrame({ raDeg: poleRaDeg, decDeg: poleDecDeg }, target)
.multiply(scratchTurn.setFromAxisAngle(Z_AXIS, turnDeg * DEG_TO_RAD))
.multiply(MAP_TO_BODY);
}
/** Where a body is from the Sun at a date, in the ICRF, AU: a moon's planet's place plus its own. */
function heliocentricPosition(body: BodyRecord, bodies: readonly BodyRecord[], jdUtc: number): CartesianCoordinates {
const jdTdb = tdbFromUtc(jdUtc);
const own = positionAtEpoch(meanElementsAt(body.orbit, body.rates, jdTdb));
const parent = body.parentBodyId ? bodies.find((candidate) => candidate.id === body.parentBodyId) : undefined;
if (!parent) {
return eclipticToEquatorial(own);
}
const offset = body.laplacePole ? laplacePlaneToEquatorial(own, body.laplacePole) : eclipticToEquatorial(own);
const centre = eclipticToEquatorial(positionAtEpoch(meanElementsAt(parent.orbit, parent.rates, jdTdb)));
return { x: centre.x + offset.x, y: centre.y + offset.y, z: centre.z + offset.z };
}
const scratchPage = new THREE.Quaternion();
const scratchPageTurn = new THREE.Quaternion();
const scratchBody = new THREE.Quaternion();
/**
* How the body page shows a body the IAU gives elements for: pole up, as the page has always
* drawn it, turned as it really is at the map's date against a Sun held at `sunAzimuthRad` round
* that pole — where the page's light has always stood, so the camera still opens on the day side.
* The Sun's height above the equator is its real one, and the face it lights is the real one:
* seen from the body, the Sun sits over the same point of its map as in the system view. What the
* page gives up is the stars, which do not turn with the body.
*
* Sets `planet` to the sphere's rotation and `sun` to the unit direction of the Sun in the page's
* frame. Returns false, touching neither, for a body without elements.
*/
export function bodyPageView(body: BodyRecord, bodies: readonly BodyRecord[], jdUtc: number, sunAzimuthRad: number, planet: THREE.Quaternion, sun: THREE.Vector3): boolean {
const elements = body.rotationalElements;
if (!elements) {
return false;
}
const { poleRaDeg, poleDecDeg } = orientationAt(elements, tdbFromUtc(jdUtc));
// From the ICRF into the body's frame with its pole on +Y, before the turn about that pole.
const toPage = poleFrame({ raDeg: poleRaDeg, decDeg: poleDecDeg }, scratchPage).multiply(MAP_TO_BODY).invert();
const position = heliocentricPosition(body, bodies, jdUtc);
sun.set(-position.x, -position.y, -position.z).normalize().applyQuaternion(toPage);
const turn = scratchPageTurn.setFromAxisAngle(Y_AXIS, sunAzimuthRad - Math.atan2(sun.x, sun.z));
sun.applyQuaternion(turn);
planet.copy(turn).multiply(toPage).multiply(bodyOrientation(elements, jdUtc, scratchBody, body.id === 'earth'));
return true;
}
@@ -0,0 +1,80 @@
/// <reference types="node" />
// Node, for the one test that reads a map's bytes from disk.
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import * as THREE from 'three/webgpu';
import { describe, expect, it } from 'vitest';
import { bodyTexturePath, saturnRing } from './texture-catalog';
describe('bodyTexturePath', () => {
it('wraps the moons and dwarf planets that have a mission mosaic in it', () => {
const mapped = ['phobos', 'deimos', 'io', 'europa', 'ganymede', 'callisto', 'mimas', 'enceladus', 'tethys', 'dione', 'rhea', 'titan', 'iapetus', 'phoebe', 'triton', 'ceres', 'pluto', 'charon'];
for (const id of mapped) {
expect(bodyTexturePath(id)).toBe(`assets/textures/bodies/${id}.jpg`);
}
});
it('wraps Venus in the map turned north up, the one checked for Maxwell Montes', () => {
// As the Solar System Scope pack ships it, venus.jpg is the Magellan map turned half round:
// Maxwell Montes, 65.2 N 3.3 E in the IAU Gazetteer, was its brightest point at 63 S 9 W. The
// file turned back puts it at 63.7 N 8.3 E; see assets/textures/README.md. No decoder runs
// here, so the check is pinned to those bytes.
expect(bodyTexturePath('venus')).toBe('assets/textures/bodies/venus.jpg');
const bytes = readFileSync(`${process.cwd()}/src/assets/textures/bodies/venus.jpg`);
expect(createHash('sha256').update(bytes).digest('hex')).toBe('4528b8e9a3cf880d80e8a5321a3001e8792bc18f266c06f3456a6bd8475e021d');
});
it('leaves the bodies with no map it could check to their derived surface', () => {
for (const id of ['miranda', 'ariel', 'umbriel', 'titania', 'oberon', 'hyperion', 'eris']) {
expect(bodyTexturePath(id)).toBeUndefined();
}
});
});
describe('saturnRing', () => {
const SATURN_RADIUS_KM = 58232;
/** Each vertex's distance from the centre, in km, beside the texture coordinate it samples. */
function radiiAndU(ring: THREE.Mesh, kmPerUnit: number): Array<{ km: number; u: number; y: number }> {
const position = ring.geometry.attributes['position'];
const uv = ring.geometry.attributes['uv'];
const vertex = new THREE.Vector3();
return Array.from({ length: position.count }, (_, i) => {
vertex.fromBufferAttribute(position, i);
return { km: vertex.length() * kmPerUnit, u: uv.getX(i), y: vertex.y };
});
}
it('reaches from 69 400 to 141 000 km, drawn against the planet at whatever size it is drawn', () => {
for (const drawnRadius of [1, 3.9e-4]) {
const radii = radiiAndU(saturnRing(SATURN_RADIUS_KM, drawnRadius), SATURN_RADIUS_KM / drawnRadius).map(({ km }) => km);
expect(Math.min(...radii)).toBeCloseTo(69400, 0);
expect(Math.max(...radii)).toBeCloseTo(141000, 0);
}
});
it('is lit, and seen from either face', () => {
// An unlit ring shows no day and night; one drawn from its front face alone vanishes when Earth
// is on its south side, as on 24 September 2026.
const material = saturnRing(SATURN_RADIUS_KM, 1).material as THREE.Material;
expect(material).toBeInstanceOf(THREE.MeshStandardMaterial);
expect(material.side).toBe(THREE.DoubleSide);
});
it('lies in the equator of a sphere built round +Y', () => {
for (const { y } of radiiAndU(saturnRing(SATURN_RADIUS_KM, 1), SATURN_RADIUS_KM)) {
expect(Math.abs(y)).toBeLessThan(1e-12);
}
});
it('samples the strip outwards, so its B ring starts at 92 000 km and its A ring ends at 136 775', () => {
// Where the strip's alpha jumps: 404.5 and 1 204 of its 1 280 px.
const vertices = radiiAndU(saturnRing(SATURN_RADIUS_KM, 1), SATURN_RADIUS_KM);
const inner = vertices.reduce((a, b) => (b.km < a.km ? b : a));
const outer = vertices.reduce((a, b) => (b.km > a.km ? b : a));
const uAt = (km: number): number => inner.u + ((km - inner.km) / (outer.km - inner.km)) * (outer.u - inner.u);
expect(Math.abs(uAt(92000) * 1280 - 404.5)).toBeLessThan(1.5);
expect(Math.abs(uAt(136775) * 1280 - 1204)).toBeLessThan(1.5);
});
});
+82 -13
View File
@@ -4,18 +4,25 @@ 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 the few imaged were seen only as points of
* of — falls through to `procedural-planet-texture.ts`, which derives a surface from the body's * light, and every moon
* own measured size, mass, orbit and host star instead. * or dwarf planet with no such map in the repository — falls through to
* `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 square photographs of them: pictures of a
* Commons — see each file's Commons page for the original credit line): * lit disc against black sky, not maps, which wrapped round a sphere put black sky on a fifth to a
* mercury/venus/earth/mars/saturn/uranus/neptune/moon/sun/saturn-ring/skybox — Solar System * third of the surface. All four are now global mosaics like the other moons'.
* Scope texture pack (CC BY 4.0); jupiter — Solar System Scope 8k pack (CC BY 4.0); pluto — *
* NASA/JHUAPL/SwRI New Horizons true-color mosaic; deimos — NASA/JPL/University of Arizona * Provenance (CC BY 4.0 Solar System Scope, via Wikimedia Commons — see each file's Commons page
* MRO HiRISE; io — NASA/JPL Galileo highest-resolution true-color mosaic; titan — NASA/JPL * for the original credit line): mercury/venus/earth/mars/saturn/uranus/neptune/moon/sun/
* Cassini true-color view. * saturn-ring/skybox — Solar System Scope texture pack; jupiter — Solar System Scope 8k pack.
*
* The moons', Ceres's and Pluto's are public-domain mission mosaics from USGS Astrogeology and the
* PDS, each put in the same frame — longitude 0 in the middle, east to the right — and each
* measured, in `assets/textures/README.md`. Where a probe saw only part of a body (Pluto, Charon,
* Triton, Phoebe, the Galilean poles), the rest is a flat grey, never invented terrain.
*/ */
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 +33,25 @@ 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',
phobos: 'assets/textures/bodies/phobos.jpg',
deimos: 'assets/textures/bodies/deimos.jpg', deimos: 'assets/textures/bodies/deimos.jpg',
io: 'assets/textures/bodies/io.jpg', io: 'assets/textures/bodies/io.jpg',
titan: 'assets/textures/bodies/titan.jpg' europa: 'assets/textures/bodies/europa.jpg',
ganymede: 'assets/textures/bodies/ganymede.jpg',
callisto: 'assets/textures/bodies/callisto.jpg',
mimas: 'assets/textures/bodies/mimas.jpg',
enceladus: 'assets/textures/bodies/enceladus.jpg',
tethys: 'assets/textures/bodies/tethys.jpg',
dione: 'assets/textures/bodies/dione.jpg',
rhea: 'assets/textures/bodies/rhea.jpg',
titan: 'assets/textures/bodies/titan.jpg',
iapetus: 'assets/textures/bodies/iapetus.jpg',
phoebe: 'assets/textures/bodies/phoebe.jpg',
triton: 'assets/textures/bodies/triton.jpg',
ceres: 'assets/textures/bodies/ceres.jpg',
pluto: 'assets/textures/bodies/pluto.jpg',
charon: 'assets/textures/bodies/charon.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. */
@@ -58,6 +79,54 @@ export function atmosphereColorFor(id: string): THREE.ColorRepresentation | unde
return ATMOSPHERE_BY_ID[id]; return ATMOSPHERE_BY_ID[id];
} }
/**
* The radii, in km from Saturn's centre, that `saturn_ring.png`'s left and right edges stand for.
*
* The strip runs straight out from its left edge to its right, and read off its alpha the ring
* edges fall where one scale puts them: the C ring's inner edge (74 490 km) at 91 of its 1 280 px,
* the B ring's inner edge (92 000) at 404.5 and outer (117 580) at 860, the A ring's outer edge
* (136 775) at 1 204 and the F ring (140 180) at 1 267.5 — all within 1.8 px of 55.9 km a pixel.
* The one miss is the Cassini Division's outer edge (122 170), which the strip draws 30 px (1 700
* km) too far in. The edges are not the 74 500 and 140 220 km of the C ring and the F ring: sized to
* those, the B ring's inner edge would sit 3 300 km out.
*/
export const SATURN_RING_INNER_KM = 69_400;
export const SATURN_RING_OUTER_KM = 141_000;
/**
* Saturn's rings, flat in the equator of a sphere built round +Y — its XZ plane — and sized
* against the planet as drawn: `drawnRadius` for Saturn's `planetRadiusKm`, so the rings keep their
* true proportion to the planet wherever it is drawn and however it is scaled.
*
* `RingGeometry`'s own UVs wrap round the angle, so u is set to the distance from the centre instead,
* which is the way the strip runs. Lit, from both faces: the face turned to the Sun is lit by the
* height of the Sun above the ring plane, and the other falls dark. Nothing in the app casts a
* shadow, so neither the planet on the rings nor the rings on the planet do.
*/
export function saturnRing(planetRadiusKm: number, drawnRadius: number): THREE.Mesh {
const unitsPerKm = drawnRadius / planetRadiusKm;
const inner = SATURN_RING_INNER_KM * unitsPerKm;
const outer = SATURN_RING_OUTER_KM * unitsPerKm;
const geometry = new THREE.RingGeometry(inner, outer, 128, 1).rotateX(-Math.PI / 2);
const position = geometry.attributes['position'];
const uv = geometry.attributes['uv'];
const vertex = new THREE.Vector3();
for (let i = 0; i < position.count; i++) {
vertex.fromBufferAttribute(position, i);
uv.setXY(i, THREE.MathUtils.clamp((vertex.length() - inner) / (outer - inner), 0, 1), 1);
}
// The strip's own alpha is the rings' opacity: dense in the B ring, thin in the C ring.
const material = new THREE.MeshStandardMaterial({
map: loadCachedTexture(SATURN_RING_TEXTURE_PATH),
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
roughness: 1,
metalness: 0
});
return new THREE.Mesh(geometry, material);
}
const textureLoader = new THREE.TextureLoader(); const textureLoader = new THREE.TextureLoader();
const loadedTextures = new Map<string, THREE.Texture>(); const loadedTextures = new Map<string, THREE.Texture>();
@@ -0,0 +1,109 @@
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Bookmark, BookmarksStore } from './bookmarks.store';
const KEY = 'star-map.bookmarks';
const SIRIUS: Bookmark = { kind: 'star', id: 32349, name: 'Sirius' };
const EARTH: Bookmark = { kind: 'body', id: 'earth', name: 'Earth' };
function store(): BookmarksStore {
// Constructed per test, because the list is read once on construction — which is the
// behaviour being tested for anything that seeds storage first.
TestBed.resetTestingModule();
return TestBed.inject(BookmarksStore);
}
describe('BookmarksStore', () => {
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
it('starts empty, and keeps what it is given', () => {
const bookmarks = store();
expect(bookmarks.bookmarks()).toEqual([]);
expect(bookmarks.toggle(SIRIUS)).toBe(true);
expect(bookmarks.bookmarks()).toEqual([SIRIUS]);
expect(bookmarks.has('star', 32349)).toBe(true);
});
it('drops what it already had, when told the same thing twice', () => {
const bookmarks = store();
bookmarks.toggle(SIRIUS);
expect(bookmarks.toggle(SIRIUS)).toBe(false);
expect(bookmarks.bookmarks()).toEqual([]);
expect(bookmarks.has('star', 32349)).toBe(false);
});
it('tells a star from a body that happen to share an id', () => {
const bookmarks = store();
bookmarks.toggle({ kind: 'star', id: 1, name: 'A star' });
bookmarks.toggle({ kind: 'body', id: 1, name: 'A body' });
expect(bookmarks.bookmarks()).toHaveLength(2);
bookmarks.remove('star', 1);
expect(bookmarks.bookmarks()).toEqual([{ kind: 'body', id: 1, name: 'A body' }]);
});
it('puts the newest first, since that is the one being come back to', () => {
const bookmarks = store();
bookmarks.toggle(SIRIUS);
bookmarks.toggle(EARTH);
expect(bookmarks.bookmarks().map((bookmark) => bookmark.name)).toEqual(['Earth', 'Sirius']);
});
it('survives the visit it was kept in', () => {
store().toggle(SIRIUS);
expect(store().bookmarks()).toEqual([SIRIUS]);
});
it('reads past whatever else is in there, rather than losing the lot', () => {
localStorage.setItem(KEY, JSON.stringify([SIRIUS, { kind: 'moon', id: 1, name: 'No such kind' }, { id: 'no-kind' }, null, 42, EARTH]));
expect(store().bookmarks()).toEqual([SIRIUS, EARTH]);
});
it('keeps one entry per place, however many the stored list holds', () => {
// Two entries for one place would each toggle the other's control on and off.
localStorage.setItem(KEY, JSON.stringify([SIRIUS, { ...SIRIUS, name: 'Sirius (again)' }]));
expect(store().bookmarks()).toEqual([SIRIUS]);
});
it('treats a store that is not a list, or not JSON at all, as no bookmarks', () => {
localStorage.setItem(KEY, '{"not":"a list"}');
expect(store().bookmarks()).toEqual([]);
localStorage.setItem(KEY, 'nonsense{');
expect(store().bookmarks()).toEqual([]);
});
it('goes on working where the browser will not store anything at all', () => {
// Private mode, a full quota, storage disabled by policy: reading throws, writing throws.
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('denied');
});
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('denied');
});
const bookmarks = store();
expect(bookmarks.bookmarks()).toEqual([]);
expect(() => bookmarks.toggle(SIRIUS)).not.toThrow();
// Kept for this visit, even though nothing will outlive it.
expect(bookmarks.bookmarks()).toEqual([SIRIUS]);
});
it('bounds what a hand-edited store can make it hold', () => {
const many = Array.from({ length: 500 }, (_, i) => ({ kind: 'star' as const, id: i, name: `Star ${i}` }));
localStorage.setItem(KEY, JSON.stringify(many));
expect(store().bookmarks()).toHaveLength(200);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { Injectable, signal } from '@angular/core';
/**
* A place someone chose to keep. Either a star, which is a system to fly into, or a body, which
* is a page to open — the two things the map lets you arrive at.
*/
export interface Bookmark {
readonly kind: 'star' | 'body';
/** A HYG star id, or a `bodies.json`/`exoplanets.json` id. */
readonly id: number | string;
/**
* The name as it read when it was kept. Stored rather than looked up, so the list can be
* shown before the catalogues have loaded — and so a bookmark to something a later catalogue
* no longer holds still says what it was rather than becoming a bare id.
*/
readonly name: string;
}
const STORAGE_KEY = 'star-map.bookmarks';
/**
* How many are kept. Not a limit anyone will reach by hand — it is a bound on what a corrupted
* or hand-edited store can make the app render, and on what is written back.
*/
const MAX_BOOKMARKS = 200;
/** `${kind}:${id}`, since a star id and a body id are different kinds of thing. */
function keyOf(kind: Bookmark['kind'], id: number | string): string {
return `${kind}:${id}`;
}
function isBookmark(value: unknown): value is Bookmark {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Partial<Bookmark>;
return (
(candidate.kind === 'star' || candidate.kind === 'body') &&
(typeof candidate.id === 'number' || typeof candidate.id === 'string') &&
typeof candidate.name === 'string'
);
}
/**
* The places kept between visits, in this browser and nowhere else.
*
* Local storage rather than an account: the map asks nobody to sign in, and a list of stars
* somebody liked is not worth a server. Every read of it is defensive — the store is a string
* a user can edit, another tab can write, and a browser can refuse to give at all — and a
* failure to read or write one is never allowed to take the map down with it.
*/
@Injectable({ providedIn: 'root' })
export class BookmarksStore {
private readonly kept = signal<readonly Bookmark[]>(this.read());
/** Most recently kept first, which is the order they are useful in. */
readonly bookmarks = this.kept.asReadonly();
has(kind: Bookmark['kind'], id: number | string): boolean {
return this.kept().some((bookmark) => bookmark.kind === kind && bookmark.id === id);
}
/** Keeps a place, or drops it if it was already kept. Returns whether it is kept now. */
toggle(bookmark: Bookmark): boolean {
const kept = this.has(bookmark.kind, bookmark.id);
this.write(kept ? this.kept().filter((other) => !(other.kind === bookmark.kind && other.id === bookmark.id)) : [bookmark, ...this.kept()].slice(0, MAX_BOOKMARKS));
return !kept;
}
remove(kind: Bookmark['kind'], id: number | string): void {
this.write(this.kept().filter((bookmark) => !(bookmark.kind === kind && bookmark.id === id)));
}
private write(bookmarks: readonly Bookmark[]): void {
this.kept.set(bookmarks);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(bookmarks));
} catch {
// Full, disabled, or private-mode storage. The list still works for this visit; it just
// will not outlive it, which is a smaller loss than the alternative of failing here.
}
}
private read(): Bookmark[] {
let raw: string | null = null;
try {
raw = localStorage.getItem(STORAGE_KEY);
} catch {
return [];
}
if (!raw) {
return [];
}
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
// Filtered rather than rejected wholesale: one bad entry should not lose the others, and
// deduplicated because two entries for one place would each toggle the other's control.
const seen = new Set<string>();
return parsed
.filter(isBookmark)
.filter((bookmark) => !seen.has(keyOf(bookmark.kind, bookmark.id)) && seen.add(keyOf(bookmark.kind, bookmark.id)))
.slice(0, MAX_BOOKMARKS)
.map(({ kind, id, name }) => ({ kind, id, name }));
} catch {
return [];
}
}
}
+126
View File
@@ -0,0 +1,126 @@
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('runs the date backwards at a negative rate', () => {
time.setRate(-86_400);
const before = time.julianDate();
vi.advanceTimersByTime(1000);
expect(time.julianDate() - before).toBeCloseTo(-1, 9);
});
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());
});
it('jumps to a date and carries on from it at the rate it was running at', () => {
vi.advanceTimersByTime(5000); // five seconds before the jump, which it must not add on
expect(time.setDate(new Date('2020-12-21T18:00Z'))).toBe(true);
// Away from now although still at real time: the rate never changed, the date did.
expect(time.atNow()).toBe(false);
vi.advanceTimersByTime(1000);
expect(time.date().toISOString()).toBe('2020-12-21T18:00:01.000Z');
time.setRate(3600);
vi.advanceTimersByTime(1000);
expect(time.date().toISOString()).toBe('2020-12-21T19:00:01.000Z');
});
it('stops a running clock at either end of the window, at real time turned back into it', () => {
time.setDate(new Date('0001-01-10T00:00Z'));
time.setRate(-2_629_800); // a month a second, backwards
vi.advanceTimersByTime(60_000);
expect(time.date().toISOString()).toBe('0001-01-01T00:00:00.000Z');
expect(time.rate()).toBe(1);
vi.advanceTimersByTime(1000);
expect(time.date().toISOString()).toBe('0001-01-01T00:00:01.000Z');
time.setDate(new Date('2999-12-01T00:00Z'));
time.setRate(2_629_800);
vi.advanceTimersByTime(60_000);
expect(time.date().toISOString()).toBe('3000-01-01T00:00:00.000Z');
expect(time.rate()).toBe(-1);
});
it('refuses a date the planets’ elements were never fitted for, and stays where it was', () => {
const before = time.date().toISOString();
expect(time.setDate(new Date('3000-01-01T00:01Z'))).toBe(false);
expect(time.setDate(new Date(Date.UTC(-100, 0, 1)))).toBe(false); // 101 BC
expect(time.setDate(new Date('not a date'))).toBe(false);
expect(time.date().toISOString()).toBe(before);
expect(time.atNow()).toBe(true);
// Both ends are in: the first day a date input can hold, and the end of Standish's fit.
expect(time.setDate(new Date('0001-01-01T00:00Z'))).toBe(true);
expect(time.date().toISOString()).toBe('0001-01-01T00:00:00.000Z');
expect(time.setDate(new Date('3000-01-01T00:00Z'))).toBe(true);
});
});
+137
View File
@@ -0,0 +1,137 @@
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 dates the clock can be set to, as `datetime-local` values read as UTC.
*
* The end is where Standish's Table 2, the mean elements that carry the planets, stops being
* fitted: it covers 3000 BC to AD 3000, and every planet was within 0.29 degrees of Horizons at
* each date measured out to 3000. The start is not the fit's but the date input's, which cannot
* go before 0001-01-01. Both are proleptic Gregorian, as a `Date` is, so before 1582 they part from
* the Julian-calendar dates history gives: two days behind them at AD 1, level from AD 200 to 300,
* ten days ahead by 1582. Pluto, on Standish's elements too, holds with them; the moons and the four
* dwarf planets from the SBDB hold for far less of it: each of their cards says how
* far its orbit strays from Horizons from 1950 to 2100 (Ceres 7.1 degrees there, 11.6 by 2200 and
* 39 by 1600).
*/
export const CLOCK_WINDOW = { min: '0001-01-01T00:00', max: '3000-01-01T00:00' } as const;
const WINDOW_MS = {
min: Date.parse(`${CLOCK_WINDOW.min}Z`),
max: Date.parse(`${CLOCK_WINDOW.max}Z`),
};
const WINDOW_JD = {
min: WINDOW_MS.min / MS_PER_DAY + JULIAN_DATE_AT_EPOCH,
max: WINDOW_MS.max / MS_PER_DAY + JULIAN_DATE_AT_EPOCH,
};
/**
* 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. A negative rate runs the same clock
* backwards: every orbit and every rotation is a function of the date, so going back is the same
* sum with the sign turned.
*/
@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, held to {@link CLOCK_WINDOW}: a
* clock run past either end stops there, at real time turned back into the window, as if the
* reader had set that date. Unheld, a month a second carried it past AD 3000, where the planets'
* elements were never fitted, and before AD 1, where `toISOString` writes a six-digit year the
* date strip, the note and the date field cut in the wrong places ("-000001-12-01 00 UTC").
*/
julianDate(): number {
const jd = this.anchorJd + ((Date.now() - this.anchorWallMs) * this.rate()) / MS_PER_DAY;
if (jd >= WINDOW_JD.min && jd <= WINDOW_JD.max) {
return jd;
}
this.anchorJd = jd < WINDOW_JD.min ? WINDOW_JD.min : WINDOW_JD.max;
this.anchorWallMs = Date.now();
this.rate.set(jd < WINDOW_JD.min ? 1 : -1);
return this.anchorJd;
}
/**
* 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);
}
}
/**
* Jumps the clock to a date, from which it carries on at whatever rate it was running at.
* Refuses one outside {@link CLOCK_WINDOW}, rather than draw planets where elements that were
* never fitted there put them.
*/
setDate(date: Date): boolean {
const ms = date.getTime();
// Written so that NaN, an unparsable field, fails it too.
if (!(ms >= WINDOW_MS.min && ms <= WINDOW_MS.max)) {
return false;
}
this.anchorJd = dateToJulianDate(date);
this.anchorWallMs = Date.now();
this.atNow.set(false);
return true;
}
/** 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);
}
}
@@ -0,0 +1,22 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
/**
* The mark on a place worth coming back to. Hollow when it is not kept, filled when it is —
* the same two states as the Display panel's layer ticks, which is where the eye has already
* learned what a filled mark means here.
*
* Size and colour come from the classes on the host; the svg fills it.
*/
@Component({
selector: 'app-bookmark-icon',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'block', 'aria-hidden': 'true' },
template: `
<svg class="h-full w-full" viewBox="0 0 24 24" [attr.fill]="kept() ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<path d="M7 4h10v16l-5-4-5 4z" />
</svg>
`
})
export class BookmarkIconComponent {
readonly kept = input(false);
}
+2086 -136
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More