Commit Graph
63 Commits
Author SHA1 Message Date
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
SenrokaiandClaude Fable 5 bd3a5a48c9 Keep a place, and come back to it
The map had no memory. Every visit started at the same overview, and a system
worth returning to had to be found again by name each time. A mark on the
readout and on a body's panel now keeps it, a Bookmarks tab lists what has
been kept, and choosing one goes there — a star by flying into its system, a
body by opening its page.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 17:06:25 +02:00
Claude 650b3b37a0 Fix what the review confirmed: anchoring, overlap, honesty, one voice
Fourteen findings from an adversarial review pass, applied together because
most share two roots — geometry written against assumptions the renderer does
not hold, and a design language spelled out longhand until it drifted.

Correctness. The leader line pointed at empty space: CSS2DObject centres a
label on its anchor by default, and margin only nudges the centred box — the
overlay now anchors the left edge (center 0, 0.5) so the hairline meets the
star. The info panel and not-found panel returned to top-4 at sm, under a
search field that is 26rem wide and centred, covering the back button on
every viewport from 640 to 1088px; they now wait for xl. The search empty
state asserted "nothing matches" while the catalogues were still loading —
and forever if they failed — so it now waits for the index. "Matches 8" was
the page size wearing the costume of a count; the header now reports the
real total, with the cap stated when it bites. The nameplate un-hides at lg
instead of sm, clear of the rail and the object card. theme-color matches
the void again.

Accessibility. Tabbing to search changed one hairline's hue; the wrapper now
carries the old ring as a visible focus indicator.

Structure. hud-surface names the panel recipe that had been inlined at seven
sites and had already forked into /85 and /92; the backdrop blur it carried
is gone from every near-opaque panel — the canvas beneath redraws every
frame, so each blur was re-sampled continuously for an effect the fill hid —
and survives only as blur-sm on the genuinely translucent search shell.
type-label and type-eyebrow name the two label voices, retiring 0.14em and
the stray hover:bg-accent/10. The Measured/Derived rows both cards pasted
twice each live once in ReadoutSectionsComponent; the reticle and chevron
each draw from a single geometry, the reticle's stroke held in screen pixels
so one mark serves every size. The acquire wipe plays once per search, not
once per keystroke, because both outcomes now share one panel. hud-banner,
which styled nothing since the rule was deleted, is a data-testid — the
convention its own file already used.

527 unit tests, 6/6 end-to-end, production build, and a driven screenshot:
every label now hangs off its star with the hairline touching the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
2026-08-17 15:19:53 +00:00
Claude b14ce78c5f Say what formatRadiusKm actually returns
Its doc comment promised a fallback to Earth radii that the function has never had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
2026-08-06 03:57:40 +00:00
Claude 7b0f32f71a Assemble a body's readouts once, not once per panel
Follow-up to review on #3. The card and the detail page each built their own Measured/Derived
split, kind label and provenance sentence — the drift buildBodyViewModel exists to prevent,
re-forked one layer up, and the drift would have been in which side of the measured/derived line
a quantity falls on, which is the distinction those panels exist to draw. One bodyReadouts(body)
now returns both blocks and the sentence, and both templates iterate it. The two surfaces render
identical rows as a result, and the card gains the inclination the detail page already showed.

Also from that review:

- KIND_LABELS was duplicated between the two panels; it now lives beside bodyReadouts. The third
  copy the review pointed at is a different union (search results are star/body/exoplanet, and
  label a body "Body"), so it stays where it is.
- CardRow was HudReadout renamed. Both are now Readout, which HudReadout extends with its
  derived flag.
- The enterable-systems count was a 21-line lazy memo over arrays that are already in hand; it is
  one expression where those arrays are assigned.
- buildBodyViewModel now carries hostStarId, so the detail scene stops rescanning both catalogues
  for something the builder had already resolved.
- heliocentricPeriodDays was called twice for the same body.
- The superscript helper was a split/map/join; it is a replace.
- info-panel had five computed() each wrapping one pure call with a non-null assertion, beside a
  template that inlined the same kind of call directly. They are gone with the shared readouts.
- Dropped a tautological test that compared a pure function to itself. Replaced with one that
  asserts the host star id the builder now carries.
- Removed the orphaned doc comment left behind when formatParsecs/formatAu moved out.

And one the review raised as out of scope but is worth taking: formatRadiusKm grouped thousands
above its decimal threshold and not below it, so 69,911 km sat beside a bare 6371 km. Both are
grouped now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
2026-08-06 03:52:11 +00:00
Claude 1019727a39 Say what is known about a world, and how it is known
Four changes to the readout panel and the body cards, which between them were showing less than
the catalogues hold and not always distinguishing a measurement from an inference.

Picking a planet used to navigate straight to /body/:id. That tore down the system scene and the
camera with it, so comparing two planets meant flying back into the system between each. Hovering
a body now raises a card over the live view and clicking pins it; Full view still opens the route
for the full 3D inspection. Clicking empty space unpins, and leaving the system clears it.

The card and the detail page were assembling "what do we know about this world" independently,
which is the shape of bug where a planet reads 255 K in one panel and 254 K in the other. Both now
build from one shared view model.

Orbital period was absent everywhere. For a heliocentric orbit it follows exactly from the
semi-major axis, because in these units the Sun's mass is the unit of mass — Mars comes back
687.0 d against a published 686.98. It is deliberately not computed for moons, whose elements are
relative to a parent planet the catalogue has no mass for, nor for exoplanets: periodDays is
populated for none of the 6319 shipped records and hostStarMassSolar for none either, so any
figure would assume a solar-mass host and mis-state every planet around an M dwarf. Where a period
does exist it is filed under Measured or Derived according to which it is, not by its field name.

The system readout showed a flat 0.00 pc for the Sun's distance, which is arithmetically right and
reads as a bug — the distance from here to here is not a measurement, so it is suppressed. It
gains the host star's luminosity, marked as derived, and counts moons separately from planets. The
neighbourhood readout gains the one thing the star field cannot show: how many of those points can
actually be entered. Derived readouts carry a marker and a footnote saying so.

Every quantity now formats through one module whose precision follows magnitude, rather than a
fixed decimal count per call site that read as false precision at one end and lost real
information at the other: 0.0026 AU stays legible instead of rounding to 0.00, and Pluto's period
reads 248 yr rather than 90560 d.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
2026-08-05 16:45:17 +00:00