Commit Graph
70 Commits
Author SHA1 Message Date
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 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 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
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
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 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 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 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 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 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 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 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
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 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 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
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
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
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