Merge pull request #1 from avalon-vanguard/claude/project-development-ehm7mw
Galactic scale, derived surfaces, a 68 388-star catalogue, and CI
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
name: CI
|
||||
|
||||
# Runs on pull requests and on the branch they merge into, so a green tick means the code was
|
||||
# checked in the state it will actually land in.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
# A second push to the same branch makes the first run's answer irrelevant.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
name: Typecheck, unit tests, build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
# `npm ci` rather than `npm install`: it installs exactly what package-lock.json pins and
|
||||
# fails if the lockfile has drifted from package.json, so CI cannot silently test a
|
||||
# different dependency tree than the one committed.
|
||||
- run: npm ci
|
||||
|
||||
# Four TypeScript projects, checked by four different things. These two have no build of
|
||||
# their own, so nothing else would ever compile them.
|
||||
- name: Typecheck the ETL
|
||||
run: npm run etl:typecheck
|
||||
|
||||
- name: Typecheck the end-to-end tests
|
||||
run: npm run e2e:typecheck
|
||||
|
||||
# `tsconfig.spec.json` is compiled here, `tsconfig.app.json` by the build below.
|
||||
- name: Unit tests
|
||||
run: npm test -- --no-watch
|
||||
|
||||
- name: Production build
|
||||
run: npm run build
|
||||
|
||||
e2e:
|
||||
name: End-to-end
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
# `--with-deps` installs the system libraries headless Chromium needs, which a bare runner
|
||||
# does not have. Only chromium: playwright.config.ts defines no other project.
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
# Playwright starts the dev server itself (see `webServer` in playwright.config.ts).
|
||||
# GitHub sets CI=true, which turns on `forbidOnly` and the two retries.
|
||||
- name: End-to-end tests
|
||||
run: npm run e2e
|
||||
|
||||
# The HTML reporter's output is the only way to see why a headless browser failed. Only
|
||||
# kept when something did fail — on a green run it is several megabytes saying so.
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
# Node modules and dependency files
|
||||
/node_modules/
|
||||
/package-lock.json
|
||||
/yarn.lock
|
||||
|
||||
# Environment files
|
||||
|
||||
@@ -108,12 +108,23 @@ interface ExoplanetRecord {
|
||||
orbit: Partial<OrbitalElements>;
|
||||
}
|
||||
|
||||
// Revised during implementation: OpenNGC publishes no distance column, and the redshift/
|
||||
// parallax fallbacks both fail for the best-known objects (M31/M33/M42 are Local Group
|
||||
// members with negative or absent redshift; a galaxy's catalog parallax comes from a
|
||||
// cross-matched foreground star). The line of sight is always known, so these are stored as
|
||||
// unit directions and drawn on a fixed backdrop shell, with distance as optional metadata.
|
||||
// See `shared/models/deepsky.model.ts`.
|
||||
interface DeepSkyRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'nebula' | 'galaxy' | 'cluster';
|
||||
x: number; y: number; z: number; // parsecs
|
||||
x: number; y: number; z: number; // unit vector on the celestial sphere, not a position
|
||||
angularSizeDeg: number;
|
||||
magnitude: number | null;
|
||||
distancePc: number | null;
|
||||
distanceMethod: 'parallax' | 'redshift' | null;
|
||||
constellation: string;
|
||||
messier: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -124,8 +135,13 @@ interface DeepSkyRecord {
|
||||
- `SystemOrbitsRenderer` — draws orbit ellipses and planet/exoplanet markers for the currently focused star system, using the Kepler propagator.
|
||||
- `KeplerPropagator` (`shared/astro/kepler.ts`) — pure function(s) converting `OrbitalElements` + epoch → Cartesian position; independently unit-testable.
|
||||
- `BodyDetailSceneComponent` — separate route/component with its own dedicated scene for a close-up view of one selected body, plus an `InfoPanelComponent` showing its data.
|
||||
- `planet-appearance.ts` / `stellar.ts` (`shared/astro/`) — derives a host star's luminosity from its catalogued magnitude and distance, a planet's equilibrium temperature and bulk density from that, and a class of world from those.
|
||||
- `procedural-planet-texture.ts` (`shared/rendering/`) — paints an equirectangular surface from that derivation: zonal bands for a fluid envelope, fractal terrain for a solid one, polar caps sized by temperature. A pure function over a byte array, no canvas.
|
||||
- `SearchComponent` — text search across `stars-index.json`, `bodies.json`, `exoplanets.json`; on match, dispatches a navigation action.
|
||||
- `NavigationStore` (Angular signals-based) — holds `viewLevel: 'galaxy' | 'system'`, `selectedStarId`, `selectedBodyId`; consumed by scene components and routed body-detail view.
|
||||
- `NavigationStore` (Angular signals-based) — holds `viewLevel: 'galactic' | 'galaxy' | 'system'`, `selectedStarId`, `selectedBodyId`; consumed by scene components and routed body-detail view.
|
||||
- `MilkyWayRenderer` / `milky-way-model.ts` — the Galaxy itself as an instanced particle cloud scattered around the structural model in `shared/astro/galaxy.ts`, crossfaded against the catalogued star field by camera distance.
|
||||
- `PolarGridPlane` / `TetherField` (`grid-plane.ts`) — the reference plane a view is read against: rings and spokes lying in a given plane, plus drop lines onto it. Used at all three scales — the galactic plane, the Sun's plane, and each system's own orbital plane.
|
||||
- `StarmapHudComponent` — the heads-up display: scale ladder, readout panel, range, reticle and frame.
|
||||
|
||||
### File Structure
|
||||
```
|
||||
@@ -270,3 +286,41 @@ Users can search by name and jump directly to the matching star, system, or body
|
||||
- Implement `SearchComponent` querying `stars-index.json`, `bodies.json`, and `exoplanets.json` for name matches.
|
||||
- On selecting a search result, dispatch the appropriate `NavigationStore` update (galaxy star, system body, or exoplanet) and trigger the corresponding camera transition or route change.
|
||||
- Ensure consistent state across `GalaxySystemSceneComponent` and `BodyDetailSceneComponent` when navigation originates from search rather than in-scene clicks.
|
||||
|
||||
### ✓ Step 7: Add the galactic scale and the heads-up display
|
||||
The map opens out from the catalogued neighbourhood to the whole Milky Way, in the visual language of the reference.
|
||||
- Add `shared/astro/galaxy.ts`: the galactic↔equatorial rotation, the Sun's galactocentric position, and logarithmic-spiral parameters per arm.
|
||||
- Add `MilkyWayRenderer`, crossfaded against the star field by camera distance so the two scales share one continuous parsec space rather than being separate scenes.
|
||||
- Add the galactic and local grid planes, star tethers, and the HUD (scale ladder, readout panel, range, reticle, frame).
|
||||
- Label the Galaxy model as a model wherever it is shown: its skeleton is measured, its particles are not.
|
||||
|
||||
### ✓ Step 8: Put a reference grid under the system view
|
||||
The same plane-and-tether reading aid the outer scales got, applied to a single system.
|
||||
- Add `systemGridRingsAu`: ring radii snapped to a 1-2-5 ladder so a distance can be read off, at any of the four orders of magnitude real systems span.
|
||||
- Draw the grid and the body tethers in the system's own reference plane — the ecliptic for the solar system, the plane of the sky otherwise — dashed, so it is never mistaken for an orbit.
|
||||
- Frame the camera against that same plane, so an exoplanet system is presented face-on rather than edge-on.
|
||||
|
||||
### ✓ Step 9: Give every body a surface
|
||||
Real photography where it exists, and a surface reasoned from measurements where it does not.
|
||||
- Derive host-star luminosity from apparent magnitude, parallax distance and a bolometric correction; validate against published values for real stars.
|
||||
- Derive equilibrium temperature and bulk density from it, and classify each world by size, temperature and density; validate against the solar system's own bodies.
|
||||
- Paint the surface procedurally from that class, seeded per body so it is stable between visits, and apply it in both the body-detail view and the system-view markers.
|
||||
- State the derivation and its limits on screen, next to the measurements it rests on.
|
||||
|
||||
### ✓ Step 10: Frame the system view from the camera it actually has
|
||||
- Replace the fixed distance-to-outermost-orbit multiple with a distance derived from the camera's vertical field of view and aspect, so what fits is a radius on screen rather than a guess.
|
||||
- Frame against the reference grid's outer ring, which is always wider than the outermost orbit, and leave an explicit margin around it.
|
||||
- Raise the framing ceiling far enough to hold the solar system out to Pluto in a portrait window; only companions hundreds of AU out reach it now.
|
||||
- Floor the star's halo against the framed radius, so a star sized against its innermost orbit still reads at the distance that frames its outermost one.
|
||||
|
||||
### ✓ Step 11: Widen the star catalogue, and separate what is drawn from what is known
|
||||
- Repack the catalogue as two binary column stores plus a string-only JSON index (`star-catalog.ts`, shared by the ETL and the app), so 7.8x the stars costs 1.7x the bytes instead of 12x.
|
||||
- Raise the distance cutoff from 50 pc to 250 pc — where Hipparcos parallaxes stop being trustworthy — taking the catalogue from 8750 stars to 68388.
|
||||
- Give the star field a render budget: every star inside 25 pc plus the brightest of the rest. Search, navigation and the cross-reference still see the whole catalogue.
|
||||
- Store each exoplanet's host coordinates and re-resolve the cross-reference against the current catalogue at build time, so widening the star list rescues systems without re-downloading the archive. Renderable systems: 371 to 609.
|
||||
|
||||
### ✓ Step 12: Aggregate additional surveys, and draw the whole catalogue
|
||||
- Raise the render budget to the full catalogue, with a `?stars=` override for machines (and test runs) that cannot draw it.
|
||||
- Add a source registry with roles — positional, enrichment, backdrop — recording what each named survey can and cannot contribute, as data the ETL prints rather than as prose in a README.
|
||||
- Add a Gaia DR3 TAP fetcher and a catalogue merge that matches on direction rather than 3D proximity, prefers the better parallax, and records provenance per star.
|
||||
- Make names dense-with-holes and add a source dictionary, so a survey-scale catalogue with no proper names does not cost 25 MB per million stars to say what its id already says.
|
||||
|
||||
@@ -1,20 +1,305 @@
|
||||
# star-map
|
||||
|
||||
Personal marketplace of `cs:*` Claude Code commands, agents, and skills.
|
||||
An interactive 3D star map in the spirit of Star Citizen's in-game starmap, but populated with
|
||||
real astronomical data instead of fictional systems. Browse the solar neighbourhood, fly into a
|
||||
star's system to see its planets on their real orbits, and drill into a single body for the
|
||||
NASA figures behind it.
|
||||
|
||||
## Install (global — works in every project)
|
||||
This repo also hosts a small Claude Code plugin marketplace — see [Plugins](#plugins) below.
|
||||
|
||||
This repo is a Claude Code plugin marketplace. Adding it and installing a
|
||||
plugin defaults to **user scope**, meaning the plugin becomes available in
|
||||
*every* project on your machine, not just the one you happen to be in:
|
||||

|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start # dev server on http://localhost:4200
|
||||
npm run build # production bundle into dist/
|
||||
```
|
||||
|
||||
Requires the Node version in `package.json`'s Angular toolchain range (Node 22.22.3+ or 24.15+).
|
||||
|
||||
```bash
|
||||
npm test # unit/component tests (Vitest, jsdom)
|
||||
npm run e2e # end-to-end tests (Playwright + Chromium) — see e2e/README.md
|
||||
npm run etl # refresh the astronomical datasets — see below
|
||||
npm run etl:typecheck # type-check the ETL scripts (they build separately from the app)
|
||||
npm run e2e:typecheck
|
||||
```
|
||||
|
||||
## What's in it
|
||||
|
||||
**Galaxy view** — the HYG catalogue out to 250 parsecs, 68 388 stars, as instanced
|
||||
camera-facing billboards positioned from real RA/Dec/parallax, coloured by spectral index and
|
||||
sized by magnitude — all of them drawn, in one instanced call, with a budget in reserve for
|
||||
catalogues larger than a GPU should be asked to hold at once (see "On how many stars" below). A
|
||||
polar grid in the galactic plane runs under them with a drop line from each of the brightest,
|
||||
and names label the most prominent stars near whatever the camera is looking at. Behind them
|
||||
sits a backdrop of notable deep-sky objects and a Milky Way panorama.
|
||||
|
||||

|
||||
|
||||
**Galactic view** — keep pulling back and the neighbourhood becomes a point inside the Milky
|
||||
Way: the bar and bulge, five spiral arms, the disc and a thin halo, with the arms and the
|
||||
galactic centre named. It is the same parsec-scale space as the galaxy view, crossfaded by
|
||||
camera distance rather than switched, so the Sun stays where it really is — 8.18 kpc out, on the
|
||||
Orion Spur, between the Sagittarius and Perseus arms. See "On the Galaxy model" below for what
|
||||
in it is measured and what is not.
|
||||
|
||||

|
||||
|
||||
**System view** — selecting a star flies the camera continuously into its system rather than
|
||||
cutting to a new scene. The Sun gets the real solar-system bodies from JPL Horizons; other
|
||||
stars get their confirmed exoplanets. Orbits are drawn as ellipses and bodies are propagated
|
||||
along them by a Kepler solver against the current epoch. Under them, a dashed grid marks out
|
||||
round distances in AU — 5 AU rings for the solar system, 0.01 AU rings for TRAPPIST-1 — with a
|
||||
drop line from each body, so eccentricity and inclination read against a circular reference
|
||||
instead of having to be inferred from a shape in space. The camera frames that grid rather than
|
||||
the orbits, from the field of view it actually has, so the outermost ring sits inside the frame
|
||||
with room around it at any system scale and any window shape.
|
||||
|
||||
The star at the centre is sized against the system's *innermost* orbit, so it can never swallow
|
||||
its closest planet, while the camera is placed to frame the *outermost* ring — and in the solar
|
||||
system those differ by a factor of a hundred. At the distance that fits Pluto in view, a disc
|
||||
that stays clear of Mercury is about a pixel across, and no radius satisfies both. So the disc
|
||||
stays honest to the orbits and the star's halo carries its visibility, floored against the framed
|
||||
radius: light is not a surface, and a glow reaching past the innermost orbit says the star is
|
||||
bright rather than that it is large. That floor is bounded from both sides — large enough that
|
||||
the star reads at a glance, small enough that Venus's and Earth's orbits stay legible as rings
|
||||
around it. Mercury's, three pixels wide at that range, does not survive either way.
|
||||
|
||||

|
||||
|
||||
**Body detail** — a dedicated close-up scene and info panel for one planet, moon or exoplanet,
|
||||
with real photography where NASA/ESA/USGS imagery exists, and a surface derived from the body's
|
||||
own measurements where it does not. See "On surfaces that were never photographed" below.
|
||||
|
||||
**Search** — name search across stars, solar-system bodies and exoplanets, navigating to the
|
||||
same place an in-scene click would.
|
||||
|
||||
Screenshots are captured from a real run of the app at `docs/screenshots/`, and are regenerated
|
||||
when the views they show change — the galactic one above records the catalogue size and reach in
|
||||
its own readout, so a stale image is visible as one.
|
||||
|
||||
### Architecture notes
|
||||
|
||||
- **Rendering** runs on Three.js `WebGPURenderer`, which falls back to a WebGL2 backend
|
||||
automatically. The render loop runs outside Angular's change detection.
|
||||
- **Stars are billboards, not points.** The WebGPU backend caps point primitives at a single
|
||||
pixel, so a points cloud renders every star as an identical dot regardless of magnitude. The
|
||||
star field is instanced quads on a `SpriteNodeMaterial` instead, which behaves the same on
|
||||
both backends. Their size is angular rather than world-space — real stars are unresolvable
|
||||
point sources, so apparent size should follow brightness, not distance.
|
||||
- **One reference frame, from three sources.** HYG gives star positions in equatorial J2000.
|
||||
JPL Horizons reports orbital elements against the ecliptic, tilted 23.4° away. The Exoplanet
|
||||
Archive measures inclination from the *plane of the sky* — perpendicular to our line of sight
|
||||
to each host star, which is why transiting planets cluster at 90°. Each set of elements is
|
||||
rotated from its own reference plane into the scene's equatorial frame, so a direction means
|
||||
the same thing everywhere. Systems are still presented face-on — by placing the camera
|
||||
relative to whichever plane that system's elements were measured in, rather than by rotating
|
||||
the world into a convenient pose. That plane is per-system, not global: one fixed viewing
|
||||
direction is face-on for the solar system and edge-on for an exoplanet system whose host star
|
||||
lies elsewhere on the sky. It is also the plane the system's reference grid lies in.
|
||||
- **Two coordinate scales.** The galaxy view works in parsecs and the system view in AU —
|
||||
about eight orders of magnitude apart, which wrecks float precision if rendered in one unit
|
||||
space. The camera rig recentres the active star to the origin ("floating origin") and swaps
|
||||
the unit scale and near/far planes at the transition point.
|
||||
- **The galactic scale is not a third space.** It is the same parsec space as the galaxy view,
|
||||
four orders of magnitude further out, so no swap is needed — the Milky Way model and the
|
||||
catalogued star field crossfade against camera distance and the depth range scales with it.
|
||||
One fixed near/far pair cannot serve both ends: flying into a star needs a near plane a
|
||||
hundredth of a parsec out, and holding the Galaxy needs a far plane a hundred thousand
|
||||
parsecs out, and a projection spanning both has no precision left to separate one arm from
|
||||
the next.
|
||||
- **No backend.** Every dataset is baked at build time into `src/assets/data/` and served as a
|
||||
static asset. Nothing queries an astronomy API at runtime.
|
||||
|
||||
### On surfaces that were never photographed
|
||||
|
||||
Fifteen bodies here have a real photograph. Everything else does not, and never will on current
|
||||
instruments: no exoplanet's surface has ever been imaged, and a few of the solar system's own
|
||||
moons have no usable map in this asset set either.
|
||||
|
||||
Those bodies get a surface reasoned from what *has* been measured, in a chain that is worth
|
||||
following because every link is standard:
|
||||
|
||||
1. The host star's **luminosity** comes from its catalogued apparent magnitude and its
|
||||
parallax distance — that pair is exactly an absolute magnitude — plus a bolometric correction
|
||||
for its spectral class. The correction is not optional: an M dwarf radiates most of its light
|
||||
in the infrared, so its visual magnitude understates it by more than tenfold, and M dwarfs are
|
||||
what most nearby planet hosts are. Good to about a factor of two, which matters less than it
|
||||
sounds: temperature goes as the fourth root.
|
||||
2. Luminosity and the planet's semi-major axis give its **equilibrium temperature**, the standard
|
||||
blackbody balance. Checked against the solar system it lands on Earth 255 K, Jupiter 112 K,
|
||||
Neptune 46 K — all within a kelvin or two of published values — and puts 51 Pegasi b at
|
||||
1227 K against a published 1200.
|
||||
3. Published mass and radius give **bulk density**, which is the difference between a ball of
|
||||
iron, of rock, of water and of hydrogen.
|
||||
4. Size fixes the family, temperature the state within it, and density overrides both at the
|
||||
extremes. That yields a class — molten, scorched, iron-rich, rocky, temperate, ice, sub-
|
||||
Neptune, ice giant, gas giant, hot gas giant — each with a palette reasoned from its chemistry.
|
||||
Methane absorbs red light, which is why the ice giants are blue; ammonia cloud tops are cream
|
||||
and ochre; silicate cloud decks over a glowing interior are why hot Jupiters are drawn deep red.
|
||||
5. The surface is then painted from that class: **zonal bands** for a body with a fluid envelope,
|
||||
because a rapidly rotating atmosphere organises into them, and fractal **terrain** for one
|
||||
with a solid surface. Polar caps grow and shrink with the derived temperature.
|
||||
|
||||
The generator samples three-dimensional noise along the sphere rather than a flat field, so
|
||||
there is no seam to stitch at the antimeridian and no pinching at the poles, and it writes into
|
||||
a plain byte array rather than a canvas — which makes it a pure function with no DOM to depend on.
|
||||
Each body's surface is seeded from its own id, so it looks the same on every visit.
|
||||
|
||||
The limits are worth stating. Equilibrium temperature ignores greenhouse warming and internal
|
||||
heat, which is why Venus comes out at 300 K against a real surface of 737 K, and why Io — kept
|
||||
molten by tidal heating — classifies as ice. Luminosity classes are often missing from the star
|
||||
catalogue, so a red giant read as a dwarf will come out too bright. And the surfaces are
|
||||
illustrations throughout: the info panel says so on every body that has one, next to the
|
||||
measurements it was reasoned from.
|
||||
|
||||
### On the Galaxy model
|
||||
|
||||
Every other dataset here is measured. The Galaxy is the exception, and not for want of trying:
|
||||
we sit inside its disc, and dust blocks the view across it, so no catalogue holds the positions
|
||||
of its stars. Every rendering of the Milky Way seen face-on — including NASA's — is a model.
|
||||
|
||||
What *is* measured is the skeleton, and that is what `shared/astro/galaxy.ts` contains: the
|
||||
directions of the galactic centre and the north galactic pole, which fix the disc's 63° tilt
|
||||
against the celestial equator; the 8.18 kpc from the Sun to the centre, from the orbit of the
|
||||
star S2 around Sgr A*; and a reference radius, azimuth and pitch angle per spiral arm,
|
||||
approximating the maser-parallax fits. The Sun's placement on the Orion Spur and the arms either
|
||||
side of it follow from those numbers rather than being posed by hand.
|
||||
|
||||
The particles scattered around that skeleton are illustrative — a seeded, reproducible cloud, not
|
||||
observations. The galactic view says so on screen, and the model fades out entirely before the
|
||||
camera reaches the catalogued 50 pc the real stars occupy.
|
||||
|
||||
## Data pipeline
|
||||
|
||||
`npm run etl` runs `tools/etl/build.ts`, which fetches each source, writes the static assets,
|
||||
then validates the combined output. Raw responses are cached under `tools/etl/.cache/`, so
|
||||
re-runs are cheap and offline-friendly; set `ETL_FORCE_REFRESH=1` to bypass the cache.
|
||||
|
||||
| Script | Source | Output |
|
||||
| --- | --- | --- |
|
||||
| `fetchStars.ts` | HYG database, plus any other positional catalogue wired in (see below) | `stars.bin`, `stars-meta.bin`, `stars-index.json` |
|
||||
| `fetchSolarSystem.ts` | JPL Horizons / SSD | `bodies.json` |
|
||||
| `fetchExoplanets.ts` | NASA Exoplanet Archive (TAP) | `exoplanets.json` |
|
||||
| `fetchDeepSky.ts` | OpenNGC | `deepsky.json` |
|
||||
|
||||
The star catalogue ships as two binary column stores plus a small JSON file, not as an array of
|
||||
objects. At 68 388 stars the old encoding — one JSON object per star, its eight key names
|
||||
repeated each time — would have been about 17 MB to download and parse before the first frame.
|
||||
Splitting it puts the numbers in `stars.bin` (positions, handed to the GPU verbatim) and
|
||||
`stars-meta.bin` (id, magnitude, colour index, spectral-type index), and leaves `stars-index.json`
|
||||
holding only the strings, with the ~2 600 distinct spectral classifications collapsed into a
|
||||
dictionary. The result is 2.6 MB for 7.8× the stars. `star-catalog.ts` defines the layout once
|
||||
and both the ETL and the app use it, so the writer and the reader cannot drift apart.
|
||||
|
||||
`ETL_STAR_DISTANCE_PC` (default `250`) sets the star-field distance cutoff.
|
||||
|
||||
### On aggregating other surveys
|
||||
|
||||
`tools/etl/sources/registry.ts` lists every catalogue the pipeline knows about, and `npm run etl`
|
||||
prints it. Sources are wired in by role, because the roles are not interchangeable:
|
||||
|
||||
| Source | Role | What it adds |
|
||||
| --- | --- | --- |
|
||||
| HYG | positional | The named, spectrally classified bright-star spine — 68 388 stars within 250 pc. |
|
||||
| Gaia DR3 | positional | Parallaxes fifty times more precise, for 1.8 billion sources. |
|
||||
| DECaPS2 | backdrop | 3.32 billion objects across the southern galactic plane. |
|
||||
| SDSS-V Milky Way Mapper | enrichment | Spectroscopic temperatures, gravities, metallicities, radial velocities. |
|
||||
| Euclid Bulge Survey (Q2) | backdrop | High-resolution imagery and astrometry of the inner bulge. |
|
||||
| SAGA | enrichment | Compiled elemental abundances for metal-poor stars. |
|
||||
|
||||
The distinction that matters is not size — it is whether a catalogue knows how **far away** its
|
||||
objects are, because a 3D map cannot place a star it only has a direction for. **Gaia is the only
|
||||
one of these that can add stars to this map**, because it is the only one that measures
|
||||
parallaxes. DECaPS2 has fifty times Gaia's object count and photometry alone: not one of its
|
||||
3.32 billion objects can be placed in depth, so it can only ever be a direction-only backdrop
|
||||
beside the deep-sky shell. Euclid's bulge sits 8 kpc away, where a parallax is microarcseconds —
|
||||
its natural contribution here is imagery, not positions. SDSS-V and SAGA are keyed to stars
|
||||
another catalogue already places; they enrich what is there and cannot extend it.
|
||||
|
||||
Where two positional catalogues overlap they are reconciled by `star-merge.ts`, which matches on
|
||||
**direction** rather than on 3D proximity. Two surveys agree on a star's direction to within an
|
||||
arcsecond and disagree on its distance by tens of per cent — so a star at 200 pc can be 50 pc
|
||||
from itself between catalogues while being unmistakably the same object. Where both have a star,
|
||||
the one with the better parallax wins; where only one reaches, the star is still there.
|
||||
|
||||
Only HYG has ever run. Every ESA, NOIRLab, SDSS and Euclid endpoint is unreachable from the
|
||||
environment this was developed in, so the Gaia query is written against the published DR3 schema
|
||||
and has not been executed against it. A source that cannot be reached is reported and skipped
|
||||
rather than failing the build.
|
||||
|
||||
### On how many stars
|
||||
|
||||
Not many, against the Galaxy. It holds 100–400 billion stars and this map ships 68 388 of them —
|
||||
about 0.00003%. That gap is not this project's to close: Gaia DR3, the largest stellar catalogue
|
||||
ever assembled, has ~1.8 billion sources, roughly 1% of the Galaxy, and is itself blocked by dust
|
||||
and blind to most red dwarfs beyond a few hundred parsecs. It is the same reason the galactic
|
||||
view is a model.
|
||||
|
||||
The 250 pc cutoff is where HYG's own measurements stop. 98.6% of its rows carry a Hipparcos
|
||||
identifier, and Hipparcos parallaxes are good to about a milliarcsecond — so at 250 pc a
|
||||
distance is uncertain by some tens of per cent and beyond it the catalogue would be plotting
|
||||
noise. Only the *radial* placement blurs; a star's direction on the sky stays exact at any
|
||||
distance. Note also that beyond about 50 pc the sample is magnitude-limited rather than
|
||||
volume-complete: it thins to the intrinsically bright, which is the same selection the naked eye
|
||||
makes.
|
||||
|
||||
Drawing and knowing are separate. The field draws `STAR_RENDER_BUDGET` stars — every one inside
|
||||
25 pc, then the brightest of the rest — while search, navigation and the planet cross-reference
|
||||
all see the full catalogue. A real GPU would draw all 68 388 without noticing; the budget exists
|
||||
for the machines that would not, and is a single constant to raise.
|
||||
|
||||
### On deep-sky distances
|
||||
|
||||
OpenNGC publishes no distance column, so distance has to be inferred — and the inference fails
|
||||
for precisely the best-known objects. M31, M33 and M42 are Local Group members whose redshift
|
||||
is negative or absent, and the catalogue's parallax for a galaxy comes from a cross-matched
|
||||
foreground star (it lists 6 mas for M31, implying 167 pc for something 780,000 pc away).
|
||||
|
||||
So deep-sky records store a **unit direction** on the celestial sphere rather than a position:
|
||||
the line of sight is always known precisely, and the objects are drawn as a fixed-radius
|
||||
backdrop shell where true distance would be unusable anyway. `distancePc` is optional metadata,
|
||||
derived from parallax for galactic objects or the Hubble law for genuinely distant galaxies,
|
||||
and left `null` — with its `distanceMethod` — whenever neither is trustworthy. Roughly 330 of
|
||||
the 463 cataloged objects get a distance; the rest honestly report none.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/app/
|
||||
core/engine/ Three.js renderer, render loop, resize
|
||||
core/data/ static-asset loading and caching
|
||||
features/galaxy-system/ shared galactic+galaxy+system scene, camera rig, star field,
|
||||
Milky Way model, grid planes, deep-sky backdrop, orbits,
|
||||
labels, HUD
|
||||
features/body-detail/ close-up scene and info panel
|
||||
features/search/ name search across every dataset
|
||||
shared/astro/ coordinates, Kepler propagator, deep-sky classification,
|
||||
Milky Way structure
|
||||
shared/models/ record contracts shared by the app and the ETL
|
||||
shared/rendering/ skybox, glow sprites, texture catalog
|
||||
shared/state/ navigation store (Angular signals)
|
||||
tools/etl/ build-time data pipeline
|
||||
e2e/ Playwright end-to-end tests
|
||||
```
|
||||
|
||||
The design document behind all of this is `.junie/plans/nasa-star-map.md`.
|
||||
|
||||
## Plugins
|
||||
|
||||
This repo doubles as a Claude Code plugin marketplace. Adding it and installing a plugin
|
||||
defaults to **user scope**, meaning the plugin becomes available in *every* project on your
|
||||
machine, not just the one you happen to be in:
|
||||
|
||||
```bash
|
||||
/plugin marketplace add avalon-vanguard/star-map
|
||||
/plugin install caveman@star-map
|
||||
```
|
||||
|
||||
Scope can be overridden at install time if you want it tied to a single
|
||||
repo instead:
|
||||
Scope can be overridden at install time if you want it tied to a single repo instead:
|
||||
|
||||
```bash
|
||||
# Shared with collaborators via that repo's .claude/settings.json
|
||||
@@ -27,9 +312,15 @@ repo instead:
|
||||
See [Claude Code plugin installation scopes](https://code.claude.com/docs/en/plugins-reference)
|
||||
for details on `user` / `project` / `local` scope.
|
||||
|
||||
## Plugins
|
||||
|
||||
- **caveman** — `/cs:caveman` ultra-compressed communication mode.
|
||||
- Command: [`commands/cs/caveman.md`](commands/cs/caveman.md)
|
||||
- Agent: [`agents/cs-caveman-mode.md`](agents/cs-caveman-mode.md)
|
||||
- Skill: [`skills/caveman/SKILL.md`](skills/caveman/SKILL.md)
|
||||
|
||||
## Data credits
|
||||
|
||||
Star catalogue: [HYG database](https://github.com/astronexus/HYG-Database) (Hipparcos, Yale
|
||||
Bright Star, Gliese) — 68 388 stars within 250 pc. Solar-system ephemerides: NASA/JPL Horizons. Exoplanets: NASA Exoplanet
|
||||
Archive. Deep-sky objects: [OpenNGC](https://github.com/mattiaverga/OpenNGC). Body and skybox
|
||||
imagery: NASA/JPL/USGS public domain and Solar System Scope (CC BY 4.0) — per-file provenance
|
||||
is recorded in `src/app/shared/rendering/texture-catalog.ts`.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 561 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
+5
-3
@@ -25,6 +25,8 @@ server a developer might already have running there.
|
||||
exactly where the default galaxy-view camera looks. `camera-flight.spec.ts` relies on this to
|
||||
reliably click-select it by clicking the center of the canvas, without needing pixel-perfect
|
||||
knowledge of the star field's on-screen layout.
|
||||
- Data (bootstrap fetch of `stars.bin`/`stars-index.json`/`bodies.json`/`exoplanets.json`) loads
|
||||
asynchronously after the page loads, so tests poll (re-click/re-check) rather than assume the
|
||||
scene is interactive immediately after `page.goto()`.
|
||||
- Data (bootstrap fetch of `stars.bin`/`stars-index.json`/`bodies.json`/`exoplanets.json`/
|
||||
`deepsky.json`) loads asynchronously after the page loads, so tests poll (re-click/re-check)
|
||||
rather than assume the scene is interactive immediately after `page.goto()`.
|
||||
- `deepsky.json` is the one dataset the scene treats as optional: it only feeds the decorative
|
||||
backdrop, so a failure to load it is logged and the star field comes up regardless.
|
||||
|
||||
@@ -4,7 +4,15 @@ import { backButtonLocator, clickCanvasUntilSystemEntered } from './support/wait
|
||||
|
||||
test.describe('Camera-flight transitions (click-to-select)', () => {
|
||||
test('clicking the star at the view center flies into its system, and the back button flies back out to the galaxy overview', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// A software-rendered bootstrap, a click-until-selected poll of up to 15 seconds, and two
|
||||
// multi-second camera flights, all while the other specs share the same CPU. The default
|
||||
// per-test budget covers that only just, and stopped covering it once a second flight-heavy
|
||||
// spec started running alongside this one.
|
||||
test.setTimeout(90_000);
|
||||
// A reduced star field, because this suite runs against a software rasterizer two orders of
|
||||
// magnitude slower than a GPU, and what is under test here is the navigation state machine
|
||||
// rather than how fast 68388 sprites rasterize. See `starRenderBudgetFromUrl`.
|
||||
await page.goto('/?stars=4000');
|
||||
|
||||
const canvas = page.getByTestId('scene-canvas');
|
||||
await expect(canvas).toBeVisible();
|
||||
|
||||
+30
-2
@@ -1,11 +1,39 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { backButtonLocator } from './support/wait-for-back-button';
|
||||
|
||||
// A reduced star field throughout: this suite runs against a software rasterizer two orders of
|
||||
// magnitude slower than a GPU, and what is under test is navigation and state rather than how
|
||||
// fast the field rasterizes. See `starRenderBudgetFromUrl`.
|
||||
test.describe('Galaxy view', () => {
|
||||
test('boots the app, initializes the 3D scene, and starts in the galaxy overview (no system controls shown)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto('/?stars=4000');
|
||||
|
||||
await expect(page.getByTestId('scene-canvas')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('Search stars, planets, exoplanets…')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Galaxy' })).toHaveCount(0);
|
||||
await expect(backButtonLocator(page)).toHaveCount(0);
|
||||
// The readout panel's own title, not just the text anywhere on screen: the selected-object
|
||||
// banner across the top names the same thing, so a bare text match is ambiguous.
|
||||
await expect(page.getByTestId('hud-title')).toHaveText('Local Stars');
|
||||
});
|
||||
|
||||
test('the scale ladder flies out to the whole Galaxy and back to the solar neighbourhood', async ({ page }) => {
|
||||
// Two multi-second camera flights, either side of a software-rendered scene bootstrap, add
|
||||
// up to more than the default per-test budget.
|
||||
test.setTimeout(90_000);
|
||||
await page.goto('/?stars=4000');
|
||||
await expect(page.getByTestId('scene-canvas')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Milky Way' }).click();
|
||||
|
||||
// The flight covers four orders of magnitude, and the readout only switches over once the
|
||||
// camera is far enough out for the Galaxy model to have taken over from the star field.
|
||||
await expect(page.getByText('Galactic Scale')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText('Sagittarius A*')).toBeVisible();
|
||||
// At the outermost scale there is nowhere further out to go.
|
||||
await expect(page.getByRole('button', { name: 'Milky Way' })).toHaveCount(0);
|
||||
|
||||
await page.getByRole('button', { name: 'Solar Neighbourhood' }).click();
|
||||
await expect(page.getByTestId('hud-title')).toHaveText('Local Stars', { timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { backButtonLocator } from './support/wait-for-back-button';
|
||||
|
||||
test.describe('Search-driven navigation', () => {
|
||||
test('selecting a star result flies into that system and shows the back-to-galaxy control', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto('/?stars=4000');
|
||||
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
|
||||
await searchInput.fill('Proxima Centauri');
|
||||
|
||||
@@ -18,7 +18,7 @@ test.describe('Search-driven navigation', () => {
|
||||
});
|
||||
|
||||
test('selecting a body result navigates straight to its detail route and shows real NASA data', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto('/?stars=4000');
|
||||
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
|
||||
await searchInput.fill('Earth');
|
||||
|
||||
@@ -35,7 +35,7 @@ test.describe('Search-driven navigation', () => {
|
||||
});
|
||||
|
||||
test('typing fewer than two characters shows no results, and Escape clears the query', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto('/?stars=4000');
|
||||
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
|
||||
|
||||
await searchInput.fill('E');
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
import { expect, Locator, Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The "Galaxy" back button is only rendered while `NavigationStore.viewLevel() === 'system'`,
|
||||
* which becomes true only once a full galaxy-to-system camera-flight transition has settled.
|
||||
* Bootstrap (data fetch + raycaster wiring) finishes asynchronously after `page.goto()`, so
|
||||
* this repeatedly clicks the canvas (re-checking first, so it never clicks a system-view body
|
||||
* marker by accident once the transition has already completed) until the button appears.
|
||||
* The HUD's scale ladder marks the level the view is currently at, and offers the others as
|
||||
* buttons. `NavigationStore.viewLevel()` becomes `'system'` only once a full galaxy-to-system
|
||||
* camera-flight transition has settled, so that marker reading "System" is the signal that the
|
||||
* transition is done.
|
||||
*/
|
||||
export function backButtonLocator(page: Page): Locator {
|
||||
return page.getByRole('button', { name: 'Galaxy' });
|
||||
export function currentLevelLocator(page: Page): Locator {
|
||||
return page.getByTestId('hud-current-level');
|
||||
}
|
||||
|
||||
/** The ladder entry that takes the view back out of a system, once there is one to leave. */
|
||||
export function backButtonLocator(page: Page): Locator {
|
||||
return page.getByRole('button', { name: 'Solar Neighbourhood' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap (data fetch + raycaster wiring) finishes asynchronously after `page.goto()`, so this
|
||||
* repeatedly clicks the canvas — re-checking first, so it never clicks a system-view body marker
|
||||
* by accident once the transition has already completed — until the view reports it is in a
|
||||
* system.
|
||||
*/
|
||||
export async function clickCanvasUntilSystemEntered(page: Page, canvas: Locator, backButton: Locator): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if (await backButton.isVisible()) {
|
||||
if ((await currentLevelLocator(page).textContent())?.trim() === 'System') {
|
||||
return true;
|
||||
}
|
||||
await canvas.click();
|
||||
@@ -24,4 +34,5 @@ export async function clickCanvasUntilSystemEntered(page: Page, canvas: Locator,
|
||||
{ timeout: 15_000, intervals: [300] }
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(backButton).toBeVisible();
|
||||
}
|
||||
|
||||
Generated
+9585
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,9 @@
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@11.12.1",
|
||||
"engines": {
|
||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/common": "^22.0.0",
|
||||
"@angular/compiler": "^22.0.0",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { BodyRecord } from '../../shared/models/body.model';
|
||||
import { DeepSkyRecord } from '../../shared/models/deepsky.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { decodeStarCatalog, StarCatalogIndex } from '../../shared/models/star-catalog';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
|
||||
export interface StarField {
|
||||
@@ -19,6 +21,7 @@ export class DataLoaderService {
|
||||
private starFieldPromise?: Promise<StarField>;
|
||||
private bodiesPromise?: Promise<BodyRecord[]>;
|
||||
private exoplanetsPromise?: Promise<ExoplanetRecord[]>;
|
||||
private deepSkyPromise?: Promise<DeepSkyRecord[]>;
|
||||
|
||||
loadStars(): Promise<StarField> {
|
||||
this.starFieldPromise ??= this.fetchStars();
|
||||
@@ -35,13 +38,25 @@ export class DataLoaderService {
|
||||
return this.exoplanetsPromise;
|
||||
}
|
||||
|
||||
loadDeepSky(): Promise<DeepSkyRecord[]> {
|
||||
this.deepSkyPromise ??= this.fetchJson<DeepSkyRecord[]>('assets/data/deepsky.json');
|
||||
return this.deepSkyPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three assets rather than one, fetched in parallel: the strings as JSON, and the numbers as
|
||||
* two binary column stores. See `star-catalog.ts` for why the catalogue is not a single array
|
||||
* of JSON objects.
|
||||
*/
|
||||
private async fetchStars(): Promise<StarField> {
|
||||
const [stars, buffer] = await Promise.all([
|
||||
fetch('assets/data/stars-index.json').then((response) => response.json() as Promise<StarRecord[]>),
|
||||
fetch('assets/data/stars.bin').then((response) => response.arrayBuffer())
|
||||
const [index, positionBuffer, metaBuffer] = await Promise.all([
|
||||
fetch('assets/data/stars-index.json').then((response) => response.json() as Promise<StarCatalogIndex>),
|
||||
fetch('assets/data/stars.bin').then((response) => response.arrayBuffer()),
|
||||
fetch('assets/data/stars-meta.bin').then((response) => response.arrayBuffer())
|
||||
]);
|
||||
|
||||
return { stars, positions: new Float32Array(buffer) };
|
||||
const positions = new Float32Array(positionBuffer);
|
||||
return { stars: decodeStarCatalog(index, positions, metaBuffer), positions };
|
||||
}
|
||||
|
||||
private fetchJson<T>(url: string): Promise<T> {
|
||||
|
||||
@@ -6,8 +6,12 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||
import { EngineService } from '../../core/engine/engine.service';
|
||||
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
||||
import { EARTH_RADIUS_KM } from '../../shared/astro/planet-appearance';
|
||||
import { luminositySolar } from '../../shared/astro/stellar';
|
||||
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
||||
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
|
||||
import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, proceduralBodyTexture, SATURN_RING_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
|
||||
import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SATURN_RING_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
|
||||
import { BodyRecord } from '../../shared/models/body.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
@@ -15,15 +19,9 @@ import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
import { BodyDetailViewModel } from './body-detail.model';
|
||||
import { InfoPanelComponent } from './info-panel.component';
|
||||
|
||||
const KIND_COLORS: Record<BodyDetailViewModel['kind'], THREE.ColorRepresentation> = {
|
||||
planet: 0x8cbfff,
|
||||
moon: 0xbfbfbf,
|
||||
dwarf: 0xccb28c,
|
||||
exoplanet: 0xd966d9
|
||||
};
|
||||
|
||||
/** Gas giants read as smoother/less rocky than terrestrial bodies under the same lighting rig. */
|
||||
const GAS_GIANT_IDS = new Set(['jupiter', 'saturn', 'uranus', 'neptune']);
|
||||
/** The body is drawn at unit radius here, so the halo's extent is its multiple directly. */
|
||||
const GLOW_SCALE = 2.6;
|
||||
|
||||
/**
|
||||
@@ -131,19 +129,24 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||
kind: body.kind,
|
||||
hostStarName: hostStar?.name ?? 'Unknown star',
|
||||
radiusKm: body.radiusKm,
|
||||
orbit: body.orbit
|
||||
orbit: body.orbit,
|
||||
appearance: appearanceForBody(body, this.bodies, this.luminosityOf(hostStar)),
|
||||
hasPhotography: bodyTexturePath(body.id) !== undefined
|
||||
});
|
||||
this.navigationStore.selectStar(body.systemStarId);
|
||||
} else if (exoplanet) {
|
||||
const hostStar = this.stars.find((star) => star.id === exoplanet.hostStarId);
|
||||
this.viewModel.set({
|
||||
id: exoplanet.id,
|
||||
name: exoplanet.name,
|
||||
kind: 'exoplanet',
|
||||
hostStarName: exoplanet.hostStarName,
|
||||
radiusKm: exoplanet.radiusEarth ? exoplanet.radiusEarth * 6371 : undefined,
|
||||
radiusKm: exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined,
|
||||
massEarth: exoplanet.massEarth,
|
||||
discoveryYear: exoplanet.discoveryYear,
|
||||
orbit: exoplanet.orbit
|
||||
orbit: exoplanet.orbit,
|
||||
appearance: appearanceForExoplanet(exoplanet, this.luminosityOf(hostStar)),
|
||||
hasPhotography: bodyTexturePath(exoplanet.id) !== undefined
|
||||
});
|
||||
if (exoplanet.hostStarId !== null) {
|
||||
this.navigationStore.selectStar(exoplanet.hostStarId);
|
||||
@@ -161,20 +164,33 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The host star's luminosity in solar units, from its own catalogued magnitude and distance.
|
||||
* `null` for an exoplanet whose host never cross-referenced to the star catalogue, which
|
||||
* leaves its planets with no derived temperature rather than a guessed one.
|
||||
*/
|
||||
private luminosityOf(star: StarRecord | undefined): number | null {
|
||||
if (!star) {
|
||||
return null;
|
||||
}
|
||||
return luminositySolar({ magnitude: star.magnitude, distancePc: Math.hypot(star.x, star.y, star.z), spectralType: star.spectralType });
|
||||
}
|
||||
|
||||
private applyViewModelToScene(): void {
|
||||
const viewModel = this.viewModel();
|
||||
if (!viewModel || !this.planetMaterial) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Real photography wherever it exists, and a surface derived from the body's own measured
|
||||
// properties wherever it does not — which is every exoplanet, since none has ever been
|
||||
// imaged, and the handful of moons no probe returned a usable map of.
|
||||
const realTexturePath = bodyTexturePath(viewModel.id);
|
||||
const texture = realTexturePath ? loadCachedTexture(realTexturePath) : proceduralBodyTexture(KIND_COLORS[viewModel.kind]);
|
||||
this.planetMaterial.map = texture ?? null;
|
||||
// A texture (real photo or procedural stand-in) supplies its own color; a plain white base
|
||||
// keeps that color true instead of tinting it through `KIND_COLORS` a second time. If no
|
||||
// texture is available at all (e.g. canvas rendering unsupported), fall back to the flat kind color.
|
||||
this.planetMaterial.color.set(texture ? 0xffffff : KIND_COLORS[viewModel.kind]);
|
||||
this.planetMaterial.roughness = GAS_GIANT_IDS.has(viewModel.id) ? 0.55 : 0.85;
|
||||
this.planetMaterial.map = realTexturePath ? loadCachedTexture(realTexturePath) : planetTexture(viewModel.appearance);
|
||||
// The texture supplies its own colour, so the base stays white rather than tinting it twice.
|
||||
this.planetMaterial.color.set(0xffffff);
|
||||
// A fluid envelope scatters light more evenly than a solid surface does.
|
||||
this.planetMaterial.roughness = GAS_GIANT_IDS.has(viewModel.id) || viewModel.appearance.palette.structure === 'banded' ? 0.55 : 0.85;
|
||||
this.planetMaterial.needsUpdate = true;
|
||||
|
||||
this.disposeRing();
|
||||
@@ -186,7 +202,7 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
const atmosphereColor = atmosphereColorFor(viewModel.id);
|
||||
if (atmosphereColor !== undefined) {
|
||||
this.glow = createGlowSprite(atmosphereColor, 1, GLOW_SCALE);
|
||||
this.glow = createGlowSprite(atmosphereColor, GLOW_SCALE);
|
||||
this.scene.add(this.glow);
|
||||
}
|
||||
}
|
||||
@@ -275,7 +291,9 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||
const geometry = new THREE.SphereGeometry(1, 64, 48);
|
||||
const viewModel = this.viewModel();
|
||||
this.planetMaterial = new THREE.MeshStandardMaterial({
|
||||
color: viewModel ? KIND_COLORS[viewModel.kind] : 0xffffff,
|
||||
// White, always: the map that arrives a moment later carries the colour, whether it is a
|
||||
// photograph or a surface derived from the body's own measurements.
|
||||
color: 0xffffff,
|
||||
roughness: 0.85,
|
||||
metalness: 0.05
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
|
||||
import { OrbitalElements } from '../../shared/models/body.model';
|
||||
|
||||
export type BodyDetailKind = 'planet' | 'moon' | 'dwarf' | 'exoplanet';
|
||||
@@ -16,4 +17,13 @@ export interface BodyDetailViewModel {
|
||||
massEarth?: number;
|
||||
discoveryYear?: number;
|
||||
orbit: Partial<OrbitalElements>;
|
||||
/**
|
||||
* What this world is inferred to look like, and the quantities that inference rests on. Always
|
||||
* present — every body has measurements enough to place it somewhere — but its individual
|
||||
* fields are nullable, since a body whose host star is not in the catalogue has no derived
|
||||
* temperature.
|
||||
*/
|
||||
appearance: PlanetAppearance;
|
||||
/** True when a real photograph is being shown rather than the derived surface. */
|
||||
hasPhotography: boolean;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DecimalPipe } from '@angular/common';
|
||||
import { Component, input } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { PLANET_CLASS_LABELS } from '../../shared/astro/planet-appearance';
|
||||
import { BodyDetailViewModel } from './body-detail.model';
|
||||
|
||||
const KIND_LABELS: Record<BodyDetailViewModel['kind'], string> = {
|
||||
@@ -60,6 +61,22 @@ const KIND_LABELS: Record<BodyDetailViewModel['kind'], string> = {
|
||||
<dd class="text-right text-text">{{ body().discoveryYear }}</dd>
|
||||
}
|
||||
</dl>
|
||||
|
||||
<p class="mt-4 mb-1.5 text-[10px] tracking-[0.18em] text-muted uppercase">Derived</p>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-y-1.5 gap-x-3 text-sm">
|
||||
<dt class="text-muted">Class</dt>
|
||||
<dd class="text-right text-text">{{ classLabel() }}</dd>
|
||||
@if (body().appearance.equilibriumTemperatureK !== null) {
|
||||
<dt class="text-muted">Equilibrium temp.</dt>
|
||||
<dd class="text-right text-text">{{ body().appearance.equilibriumTemperatureK | number: '1.0-0' }} K</dd>
|
||||
}
|
||||
@if (body().appearance.bulkDensityGramsPerCm3 !== null) {
|
||||
<dt class="text-muted">Bulk density</dt>
|
||||
<dd class="text-right text-text">{{ body().appearance.bulkDensityGramsPerCm3 | number: '1.0-2' }} g/cm³</dd>
|
||||
}
|
||||
</dl>
|
||||
|
||||
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed text-muted">{{ surfaceProvenance() }}</p>
|
||||
</div>
|
||||
`,
|
||||
imports: [DecimalPipe]
|
||||
@@ -73,6 +90,25 @@ export class InfoPanelComponent {
|
||||
return KIND_LABELS[this.body().kind];
|
||||
}
|
||||
|
||||
classLabel(): string {
|
||||
return PLANET_CLASS_LABELS[this.body().appearance.planetClass];
|
||||
}
|
||||
|
||||
/**
|
||||
* Says plainly which of the two the viewer is looking at. The derived surface is a reasoned
|
||||
* illustration, and a panel of real measurements sitting next to it is exactly the context in
|
||||
* which it could be mistaken for another one.
|
||||
*/
|
||||
surfaceProvenance(): string {
|
||||
if (this.body().hasPhotography) {
|
||||
return 'Surface: NASA/ESA/USGS photography.';
|
||||
}
|
||||
const temperature = this.body().appearance.equilibriumTemperatureK;
|
||||
return temperature === null
|
||||
? 'Surface illustrated from this body’s measured size and mass. Its host star is not in the catalogue, so no temperature could be derived. Not an observation — no image of this world exists.'
|
||||
: 'Surface illustrated from the measurements above — size, density and the temperature derived from its star’s output and its orbit. Not an observation — no image of this world exists.';
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
void this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DeepSkyRecord } from '../../shared/models/deepsky.model';
|
||||
import { backdropPosition, backdropSpriteSizePc, BACKDROP_RADIUS_PC, brightnessBandIndex, deepSkyLabelPoints, DeepSkyRenderer } from './deep-sky-renderer';
|
||||
|
||||
function record(overrides: Partial<DeepSkyRecord> = {}): DeepSkyRecord {
|
||||
return {
|
||||
id: 'NGC0224',
|
||||
name: 'Andromeda Galaxy',
|
||||
kind: 'galaxy',
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
angularSizeDeg: 2.96,
|
||||
magnitude: 3.44,
|
||||
distancePc: null,
|
||||
distanceMethod: null,
|
||||
constellation: 'And',
|
||||
messier: 'M31',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('backdropPosition', () => {
|
||||
it('pushes the direction out to the shell radius', () => {
|
||||
const position = backdropPosition(record({ x: 0, y: 0, z: 1 }));
|
||||
expect(position.z).toBeCloseTo(BACKDROP_RADIUS_PC, 9);
|
||||
expect(position.length()).toBeCloseTo(BACKDROP_RADIUS_PC, 9);
|
||||
});
|
||||
|
||||
it('preserves direction for an off-axis object', () => {
|
||||
const direction = new THREE.Vector3(0.3, -0.5, 0.81).normalize();
|
||||
const position = backdropPosition(record({ x: direction.x, y: direction.y, z: direction.z }));
|
||||
|
||||
expect(position.length()).toBeCloseTo(BACKDROP_RADIUS_PC, 6);
|
||||
expect(position.clone().normalize().dot(direction)).toBeCloseTo(1, 9);
|
||||
});
|
||||
|
||||
it('honours an explicit radius', () => {
|
||||
expect(backdropPosition(record(), 100).length()).toBeCloseTo(100, 9);
|
||||
});
|
||||
|
||||
it('lands inside the galaxy camera frustum from anywhere on its orbit', () => {
|
||||
// The camera orbits at most 2000 pc out and its far plane is 5000 pc, so the far side of
|
||||
// the shell has to stay within reach or the backdrop would be clipped away.
|
||||
expect(BACKDROP_RADIUS_PC).toBeGreaterThan(2000);
|
||||
expect(BACKDROP_RADIUS_PC + 2000).toBeLessThan(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backdropSpriteSizePc', () => {
|
||||
it('scales with true angular size', () => {
|
||||
const small = backdropSpriteSizePc(record({ angularSizeDeg: 1 }));
|
||||
const large = backdropSpriteSizePc(record({ angularSizeDeg: 2 }));
|
||||
expect(large).toBeGreaterThan(small);
|
||||
});
|
||||
|
||||
it('reproduces the real angular size in the unclamped range', () => {
|
||||
// 2 degrees at the shell radius: r * theta.
|
||||
const expected = BACKDROP_RADIUS_PC * 2 * (Math.PI / 180);
|
||||
expect(backdropSpriteSizePc(record({ angularSizeDeg: 2 }))).toBeCloseTo(expected, 6);
|
||||
});
|
||||
|
||||
it('floors sub-arcminute objects so they stay visible', () => {
|
||||
const tiny = backdropSpriteSizePc(record({ angularSizeDeg: 0 }));
|
||||
expect(tiny).toBeGreaterThan(0);
|
||||
expect(tiny).toBe(backdropSpriteSizePc(record({ angularSizeDeg: 0.001 })));
|
||||
});
|
||||
|
||||
it('caps very extended objects so they cannot blanket the view', () => {
|
||||
const huge = backdropSpriteSizePc(record({ angularSizeDeg: 90 }));
|
||||
const larger = backdropSpriteSizePc(record({ angularSizeDeg: 180 }));
|
||||
expect(huge).toBe(larger);
|
||||
});
|
||||
});
|
||||
|
||||
describe('brightnessBandIndex', () => {
|
||||
it('puts the brightest objects in the most opaque band', () => {
|
||||
expect(brightnessBandIndex(3.44)).toBe(0);
|
||||
});
|
||||
|
||||
it('separates mid and faint objects into later bands', () => {
|
||||
expect(brightnessBandIndex(6)).toBe(1);
|
||||
expect(brightnessBandIndex(9)).toBe(2);
|
||||
});
|
||||
|
||||
it('is monotonic in magnitude', () => {
|
||||
const bands = [0, 3, 5, 6, 7.5, 9, 14].map(brightnessBandIndex);
|
||||
expect([...bands].sort((a, b) => a - b)).toEqual(bands);
|
||||
});
|
||||
|
||||
it('treats an unphotometered object as faintest rather than brightest', () => {
|
||||
expect(brightnessBandIndex(null)).toBe(brightnessBandIndex(99));
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepSkyLabelPoints', () => {
|
||||
const records = [record({ id: 'a', name: 'A' }), record({ id: 'b', name: 'B' }), record({ id: 'c', name: 'C' })];
|
||||
|
||||
it('takes a prefix of the (magnitude-sorted) records', () => {
|
||||
expect(deepSkyLabelPoints(records, 2).map((point) => point.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('anchors each label on the backdrop shell', () => {
|
||||
const [point] = deepSkyLabelPoints(records, 1);
|
||||
expect(Math.hypot(point.x, point.y, point.z)).toBeCloseTo(BACKDROP_RADIUS_PC, 6);
|
||||
});
|
||||
|
||||
it('carries the display name and the catalog id', () => {
|
||||
const [point] = deepSkyLabelPoints(records, 1);
|
||||
expect(point).toMatchObject({ id: 'a', name: 'A' });
|
||||
});
|
||||
|
||||
it('never returns more labels than there are records', () => {
|
||||
expect(deepSkyLabelPoints(records, 99)).toHaveLength(3);
|
||||
expect(deepSkyLabelPoints([], 5)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeepSkyRenderer', () => {
|
||||
it('adds one sprite per record', () => {
|
||||
const renderer = new DeepSkyRenderer([record({ id: 'a' }), record({ id: 'b' })]);
|
||||
expect(renderer.object.children).toHaveLength(2);
|
||||
expect(renderer.object.children.every((child) => child instanceof THREE.Sprite)).toBe(true);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('positions and scales each sprite from its record', () => {
|
||||
const only = record({ angularSizeDeg: 2 });
|
||||
const renderer = new DeepSkyRenderer([only]);
|
||||
const sprite = renderer.object.children[0] as THREE.Sprite;
|
||||
|
||||
expect(sprite.position.length()).toBeCloseTo(BACKDROP_RADIUS_PC, 6);
|
||||
expect(sprite.scale.x).toBeCloseTo(backdropSpriteSizePc(only), 6);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('shares one material across objects of the same kind and brightness', () => {
|
||||
const renderer = new DeepSkyRenderer([
|
||||
record({ id: 'a', kind: 'galaxy', magnitude: 3 }),
|
||||
record({ id: 'b', kind: 'galaxy', magnitude: 4 })
|
||||
]);
|
||||
const [first, second] = renderer.object.children as THREE.Sprite[];
|
||||
|
||||
expect(first.material).toBe(second.material);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('gives different kinds different materials', () => {
|
||||
const renderer = new DeepSkyRenderer([
|
||||
record({ id: 'a', kind: 'galaxy', magnitude: 3 }),
|
||||
record({ id: 'b', kind: 'nebula', magnitude: 3 }),
|
||||
record({ id: 'c', kind: 'cluster', magnitude: 3 })
|
||||
]);
|
||||
const materials = new Set((renderer.object.children as THREE.Sprite[]).map((sprite) => sprite.material));
|
||||
|
||||
expect(materials.size).toBe(3);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('gives different brightness bands different materials', () => {
|
||||
const renderer = new DeepSkyRenderer([
|
||||
record({ id: 'a', kind: 'galaxy', magnitude: 3 }),
|
||||
record({ id: 'b', kind: 'galaxy', magnitude: 9 })
|
||||
]);
|
||||
const [bright, faint] = renderer.object.children as THREE.Sprite[];
|
||||
|
||||
expect(bright.material).not.toBe(faint.material);
|
||||
expect(bright.material.opacity).toBeGreaterThan(faint.material.opacity);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('keeps the material count bounded no matter how many objects there are', () => {
|
||||
const many = Array.from({ length: 200 }, (_, index) =>
|
||||
record({ id: `obj-${index}`, kind: (['galaxy', 'nebula', 'cluster'] as const)[index % 3], magnitude: index % 12 })
|
||||
);
|
||||
const renderer = new DeepSkyRenderer(many);
|
||||
const materials = new Set((renderer.object.children as THREE.Sprite[]).map((sprite) => sprite.material));
|
||||
|
||||
expect(renderer.object.children).toHaveLength(200);
|
||||
// Three kinds x three brightness bands.
|
||||
expect(materials.size).toBeLessThanOrEqual(9);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('renders behind the star field', () => {
|
||||
const renderer = new DeepSkyRenderer([record()]);
|
||||
expect(renderer.object.renderOrder).toBeLessThan(0);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('disposes its materials and empties the group', () => {
|
||||
const renderer = new DeepSkyRenderer([record({ id: 'a' }), record({ id: 'b', kind: 'nebula' })]);
|
||||
const materials = (renderer.object.children as THREE.Sprite[]).map((sprite) => sprite.material);
|
||||
const disposed = materials.map((material) => {
|
||||
let seen = false;
|
||||
material.addEventListener('dispose', () => (seen = true));
|
||||
return () => seen;
|
||||
});
|
||||
|
||||
renderer.dispose();
|
||||
|
||||
expect(renderer.object.children).toHaveLength(0);
|
||||
expect(disposed.every((wasDisposed) => wasDisposed())).toBe(true);
|
||||
});
|
||||
|
||||
it('handles an empty catalog', () => {
|
||||
const renderer = new DeepSkyRenderer([]);
|
||||
expect(renderer.object.children).toHaveLength(0);
|
||||
expect(renderer.labelPoints(5)).toEqual([]);
|
||||
renderer.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
|
||||
import { DeepSkyKind, DeepSkyRecord } from '../../shared/models/deepsky.model';
|
||||
import { createGlowTexture } from '../../shared/rendering/skybox';
|
||||
import { LabeledPoint } from './star-label-overlay';
|
||||
|
||||
/**
|
||||
* Radius (parsecs) of the shell the backdrop is painted on.
|
||||
*
|
||||
* Chosen to sit clear of the local star field it is a backdrop for: well outside its 50 pc
|
||||
* radius, and close enough that the far side of the shell stays inside the local view's far
|
||||
* plane rather than being clipped away.
|
||||
*
|
||||
* The shell only makes sense from inside it — it is the sky as seen from the Sun, with every
|
||||
* object's true distance flattened onto one radius. The camera can now pull back far past it to
|
||||
* the galactic scale, so {@link DeepSkyRenderer.setStrength} fades it out on the way rather than
|
||||
* letting the view fly through a wall of nebulae.
|
||||
*/
|
||||
export const BACKDROP_RADIUS_PC = 2500;
|
||||
|
||||
/**
|
||||
* Apparent-size clamps (parsecs at {@link BACKDROP_RADIUS_PC}) for a backdrop sprite. The floor
|
||||
* is generous — most catalog objects are a few arcminutes across, and at this shell radius that
|
||||
* is a pixel or two — so they read as haze rather than as another star.
|
||||
*/
|
||||
const MIN_SPRITE_SIZE_PC = 70;
|
||||
const MAX_SPRITE_SIZE_PC = 340;
|
||||
|
||||
const DEGREES_TO_RADIANS = Math.PI / 180;
|
||||
|
||||
/** Loosely evocative of each class's real appearance in long-exposure photography. */
|
||||
const KIND_COLORS: Readonly<Record<DeepSkyKind, number>> = {
|
||||
galaxy: 0xffd9a0,
|
||||
nebula: 0xff86b0,
|
||||
cluster: 0xa8c8ff
|
||||
};
|
||||
|
||||
/**
|
||||
* Opacity bands by apparent magnitude. Sprites share a material per (kind, band), so
|
||||
* brightness is quantised rather than continuous — nine materials instead of one per object,
|
||||
* which keeps 400-odd backdrop sprites cheap to build and dispose.
|
||||
*/
|
||||
const BRIGHTNESS_BANDS: readonly { maxMagnitude: number; opacity: number }[] = [
|
||||
{ maxMagnitude: 5, opacity: 0.5 },
|
||||
{ maxMagnitude: 7.5, opacity: 0.3 },
|
||||
{ maxMagnitude: Infinity, opacity: 0.16 }
|
||||
];
|
||||
|
||||
/**
|
||||
* Where a deep-sky object lands on the backdrop shell. The record stores a unit direction,
|
||||
* so this is just that direction pushed out to the shell radius.
|
||||
*/
|
||||
export function backdropPosition(record: DeepSkyRecord, radiusPc = BACKDROP_RADIUS_PC): THREE.Vector3 {
|
||||
return new THREE.Vector3(record.x, record.y, record.z).multiplyScalar(radiusPc);
|
||||
}
|
||||
|
||||
/**
|
||||
* On-shell size for an object, from its true angular size — so the backdrop reproduces the
|
||||
* real sky, where the Andromeda Galaxy is six times wider than the full Moon.
|
||||
*
|
||||
* Clamped at both ends: without a floor, the many sub-arcminute objects would be invisible
|
||||
* specks, and without a ceiling a handful of very extended objects would blanket the view.
|
||||
*/
|
||||
export function backdropSpriteSizePc(record: DeepSkyRecord, radiusPc = BACKDROP_RADIUS_PC): number {
|
||||
const trueSize = radiusPc * record.angularSizeDeg * DEGREES_TO_RADIANS;
|
||||
return THREE.MathUtils.clamp(trueSize, MIN_SPRITE_SIZE_PC, MAX_SPRITE_SIZE_PC);
|
||||
}
|
||||
|
||||
/** Index into {@link BRIGHTNESS_BANDS}; unphotometered objects fall into the faintest band. */
|
||||
export function brightnessBandIndex(magnitude: number | null): number {
|
||||
if (magnitude === null) {
|
||||
return BRIGHTNESS_BANDS.length - 1;
|
||||
}
|
||||
const index = BRIGHTNESS_BANDS.findIndex((band) => magnitude <= band.maxMagnitude);
|
||||
return index === -1 ? BRIGHTNESS_BANDS.length - 1 : index;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `limit` most prominent objects, as label anchors on the backdrop shell. Prominence is
|
||||
* apparent magnitude, which is the order the ETL already writes, so this is a prefix of the
|
||||
* records that actually have a name worth showing.
|
||||
*/
|
||||
export function deepSkyLabelPoints(
|
||||
records: readonly DeepSkyRecord[],
|
||||
limit: number,
|
||||
radiusPc = BACKDROP_RADIUS_PC
|
||||
): LabeledPoint[] {
|
||||
return records.slice(0, limit).map((record) => {
|
||||
const position = backdropPosition(record, radiusPc);
|
||||
return { id: record.id, name: record.name, kind: record.kind.toUpperCase(), x: position.x, y: position.y, z: position.z };
|
||||
});
|
||||
}
|
||||
|
||||
/** Material keys are `kind:band`; the band is what fixes an object's base opacity. */
|
||||
function bandIndexFromKey(key: string): number {
|
||||
return Number(key.slice(key.indexOf(':') + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints the notable deep-sky objects from `deepsky.json` onto a fixed shell around the star
|
||||
* field, as soft additive billboards coloured by kind and sized by real angular extent.
|
||||
*
|
||||
* Billboards rather than a single `THREE.Points` cloud: the WebGPU backend caps point
|
||||
* primitives at one pixel (see `StarFieldRenderer`), which would reduce the Orion Nebula to a
|
||||
* dot. Sprites cost one draw call each, so materials are shared across all of them and the
|
||||
* catalog is pre-filtered by the ETL to the few hundred objects actually worth drawing.
|
||||
*/
|
||||
export class DeepSkyRenderer {
|
||||
readonly object = new THREE.Group();
|
||||
|
||||
private readonly materials = new Map<string, THREE.SpriteMaterial>();
|
||||
|
||||
constructor(
|
||||
private readonly records: readonly DeepSkyRecord[],
|
||||
private readonly radiusPc = BACKDROP_RADIUS_PC
|
||||
) {
|
||||
// Drawn before the star field so the stars composite on top of the glow.
|
||||
this.object.renderOrder = -1;
|
||||
|
||||
for (const record of records) {
|
||||
const sprite = new THREE.Sprite(this.materialFor(record));
|
||||
sprite.position.copy(backdropPosition(record, this.radiusPc));
|
||||
sprite.scale.setScalar(backdropSpriteSizePc(record, this.radiusPc));
|
||||
this.object.add(sprite);
|
||||
}
|
||||
}
|
||||
|
||||
/** Label anchors for the brightest `limit` objects on this backdrop. */
|
||||
labelPoints(limit: number): LabeledPoint[] {
|
||||
return deepSkyLabelPoints(this.records, limit, this.radiusPc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scales the whole backdrop's opacity, keeping each object's brightness band relative to the
|
||||
* others. Used to dissolve the shell as the camera leaves the neighbourhood it belongs to.
|
||||
*/
|
||||
setStrength(strength: number): void {
|
||||
const clamped = THREE.MathUtils.clamp(strength, 0, 1);
|
||||
for (const [key, material] of this.materials) {
|
||||
material.opacity = BRIGHTNESS_BANDS[bandIndexFromKey(key)].opacity * clamped;
|
||||
}
|
||||
this.object.visible = clamped > 0;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const material of this.materials.values()) {
|
||||
material.dispose();
|
||||
}
|
||||
this.materials.clear();
|
||||
this.object.clear();
|
||||
}
|
||||
|
||||
private materialFor(record: DeepSkyRecord): THREE.SpriteMaterial {
|
||||
const band = brightnessBandIndex(record.magnitude);
|
||||
const key = `${record.kind}:${band}`;
|
||||
|
||||
let material = this.materials.get(key);
|
||||
if (!material) {
|
||||
const color = KIND_COLORS[record.kind];
|
||||
material = new THREE.SpriteMaterial({
|
||||
map: createGlowTexture(color, 'diffuse'),
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: BRIGHTNESS_BANDS[band].opacity,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
this.materials.set(key, material);
|
||||
}
|
||||
return material;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DataLoaderService, StarField } from '../../core/data/data-loader.service';
|
||||
import { EngineService, EngineTickCallback } from '../../core/engine/engine.service';
|
||||
import { BodyRecord } from '../../shared/models/body.model';
|
||||
import { DeepSkyRecord } from '../../shared/models/deepsky.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
@@ -26,6 +27,21 @@ const PROXIMA: StarRecord = { id: 2, name: 'Proxima Centauri', x: 0, y: 1.3, z:
|
||||
const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA];
|
||||
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
|
||||
|
||||
const DEEP_SKY_OBJECT: DeepSkyRecord = {
|
||||
id: 'NGC0224',
|
||||
name: 'Andromeda Galaxy',
|
||||
kind: 'galaxy',
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
angularSizeDeg: 2.96,
|
||||
magnitude: 3.44,
|
||||
distancePc: null,
|
||||
distanceMethod: null,
|
||||
constellation: 'And',
|
||||
messier: 'M31'
|
||||
};
|
||||
|
||||
const EARTH: BodyRecord = {
|
||||
id: 'earth',
|
||||
systemStarId: SUN.id,
|
||||
@@ -99,6 +115,10 @@ class FakeDataLoaderService {
|
||||
loadExoplanets(): Promise<ExoplanetRecord[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
loadDeepSky(): Promise<DeepSkyRecord[]> {
|
||||
return Promise.resolve([DEEP_SKY_OBJECT]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits out several macrotask turns so chained promises (bootstrap's awaits) settle. */
|
||||
@@ -191,7 +211,13 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
const component = fixture.componentInstance as unknown as { galaxyGroup: THREE.Group; systemGroup: THREE.Group };
|
||||
expect(component.galaxyGroup.visible).toBe(true);
|
||||
expect(component.systemGroup.visible).toBe(false);
|
||||
expect(engine.getCamera().near).toBeCloseTo(0.01, 9);
|
||||
// Parsec-scale rather than an exact figure: in galaxy space the depth range scales with how
|
||||
// far the camera has pulled back, so what identifies it is the far plane it settles on
|
||||
// (5000 pc) versus the AU-space one (20000 AU), not a fixed near plane.
|
||||
expect(engine.getCamera().far).toBeCloseTo(5000, 6);
|
||||
// The near plane tracks how far back the camera is rather than sitting at a constant, so
|
||||
// what identifies galaxy space is that it is a small fraction of that far plane.
|
||||
expect(engine.getCamera().near).toBeLessThan(engine.getCamera().far / 1000);
|
||||
expect(navigationStore.viewLevel()).toBe('galaxy');
|
||||
});
|
||||
|
||||
@@ -210,6 +236,59 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
expect(component.currentStarId).toBe(ALPHA_CENTAURI.id);
|
||||
});
|
||||
|
||||
it('reports the galactic scale once the camera has pulled back far enough, and comes back', async () => {
|
||||
const camera = engine.getCamera();
|
||||
|
||||
camera.position.set(0, 0, 30000);
|
||||
await advanceFrames(engine, 0.3);
|
||||
expect(navigationStore.viewLevel()).toBe('galactic');
|
||||
|
||||
camera.position.set(0, 15, 30);
|
||||
await advanceFrames(engine, 0.3);
|
||||
expect(navigationStore.viewLevel()).toBe('galaxy');
|
||||
});
|
||||
|
||||
it('widens the depth range as the camera pulls back, instead of holding one range for both scales', async () => {
|
||||
const camera = engine.getCamera();
|
||||
|
||||
await advanceFrames(engine, 0.3);
|
||||
const localFar = camera.far;
|
||||
|
||||
camera.position.set(0, 0, 30000);
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
expect(camera.far).toBeGreaterThan(localFar);
|
||||
// A near plane a hundredth of a parsec out has no precision left to spare at this range.
|
||||
expect(camera.near).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('flies out to the Galaxy when the scale ladder asks for it', async () => {
|
||||
const camera = engine.getCamera();
|
||||
fixture.componentInstance.goToLevel('galactic');
|
||||
await advanceFrames(engine, 3);
|
||||
|
||||
expect(camera.position.length()).toBeGreaterThan(10000);
|
||||
expect(navigationStore.viewLevel()).toBe('galactic');
|
||||
});
|
||||
|
||||
it('leaves the system first when the scale ladder is used from inside one', async () => {
|
||||
navigationStore.selectStar(SUN.id);
|
||||
await flushAsync();
|
||||
await advanceFrames(engine, 2.5);
|
||||
expect(navigationStore.viewLevel()).toBe('system');
|
||||
|
||||
fixture.componentInstance.goToLevel('galactic');
|
||||
await flushAsync();
|
||||
// Exit leg, then the return leg, then the galactic flight: the request has to wait out the
|
||||
// unit-space unwind rather than firing a parsec-scale flight while the scene is in AU.
|
||||
await advanceFrames(engine, 6);
|
||||
|
||||
const component = fixture.componentInstance as unknown as { currentStarId: number | null; systemGroup: THREE.Group };
|
||||
expect(component.currentStarId).toBeNull();
|
||||
expect(component.systemGroup.visible).toBe(false);
|
||||
expect(navigationStore.viewLevel()).toBe('galactic');
|
||||
});
|
||||
|
||||
it('ignores a new selection while a transition is already in flight, then resolves to the latest requested star once idle', async () => {
|
||||
navigationStore.selectStar(SUN.id);
|
||||
await flushAsync();
|
||||
|
||||
@@ -1,44 +1,115 @@
|
||||
import { AfterViewInit, Component, effect, ElementRef, OnDestroy, viewChild } from '@angular/core';
|
||||
import { AfterViewInit, Component, effect, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
import { dateToJulianDate } from '../../shared/astro/constants';
|
||||
import { galacticCentrePositionPc, galacticToEquatorial, MILKY_WAY_ARMS, SUN_GALACTOCENTRIC_RADIUS_PC } from '../../shared/astro/galaxy';
|
||||
import { luminositySolar } from '../../shared/astro/stellar';
|
||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||
import { EngineService } from '../../core/engine/engine.service';
|
||||
import { BodyRecord } from '../../shared/models/body.model';
|
||||
import { DeepSkyRecord } from '../../shared/models/deepsky.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
|
||||
import { loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
import { NavigationStore, ViewLevel } from '../../shared/state/navigation.store';
|
||||
import { CameraRigController } from './camera-rig-controller';
|
||||
import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer';
|
||||
import { StarLabelOverlay } from './star-label-overlay';
|
||||
import { DeepSkyRenderer } from './deep-sky-renderer';
|
||||
import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane';
|
||||
import { MilkyWayRenderer } from './milky-way-renderer';
|
||||
import { starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramingDistanceAu, systemViewDirection } from './system-framing';
|
||||
import { HudReadout, StarmapHudComponent } from './starmap-hud.component';
|
||||
import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
|
||||
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
|
||||
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
||||
|
||||
/** HYG catalog id for the Sun itself — the only star we have a real close-up photo of. */
|
||||
const SOL_STAR_ID = 0;
|
||||
const SUN_GLOW_SCALE = 3.2;
|
||||
/** Stars drawn from a colour rather than a photograph get a more restrained halo. */
|
||||
const DIM_STAR_GLOW_SCALE = 0.6;
|
||||
|
||||
/** Stars closer than this to the camera get a name label (always includes the selection). */
|
||||
const LABEL_MAX_DISTANCE_PC = 20;
|
||||
/**
|
||||
* How far from what the camera is looking at a star can be and still be named, as a fraction of
|
||||
* how far back the camera is — so the net widens as the view pulls out and closes as it dives
|
||||
* in, instead of naming the same handful of stars at every scale. Bounded at both ends.
|
||||
*/
|
||||
const LABEL_RADIUS_TO_ORBIT_DISTANCE = 0.35;
|
||||
const MIN_LABEL_RADIUS_PC = 4;
|
||||
const MAX_LABEL_RADIUS_PC = 400;
|
||||
/** Caps how many labels are shown at once, to keep the DOM light. */
|
||||
const LABEL_MAX_COUNT = 15;
|
||||
/**
|
||||
* Minimum on-screen separation between two labels, in NDC (roughly 6% of the viewport height).
|
||||
* Nearer stars win the space; see `spreadLabels`.
|
||||
*/
|
||||
const LABEL_MIN_SEPARATION_NDC = 0.12;
|
||||
/**
|
||||
* How many deep-sky objects get a permanent label. These sit on a fixed backdrop shell rather
|
||||
* than near the camera, so proximity is meaningless for them — the brightest handful are simply
|
||||
* always named.
|
||||
*/
|
||||
const DEEP_SKY_LABEL_COUNT = 12;
|
||||
/** How often (seconds) the visible label set is recomputed; doesn't need to be per-frame. */
|
||||
const LABEL_UPDATE_INTERVAL_SECONDS = 0.2;
|
||||
/** Raycast pick tolerance around each star point, in parsecs. */
|
||||
const PICK_THRESHOLD_PC = 1.2;
|
||||
/** Pointer travel (px) above which a press counts as an orbit drag rather than a selection. */
|
||||
const CLICK_DRAG_SLOP_PX = 5;
|
||||
|
||||
const GALAXY_OVERVIEW_POSITION = new THREE.Vector3(0, 15, 30);
|
||||
/**
|
||||
* Opening pose for the local view, expressed in the galactic frame rather than the equatorial
|
||||
* one: about 35 degrees above the galactic plane, looking down at the Sun. Picked so the grid
|
||||
* reads as a floor under the star field instead of slicing across it edge-on, which is what an
|
||||
* arbitrary equatorial direction gives — the plane is tilted 63 degrees to the equator.
|
||||
*/
|
||||
const GALAXY_OVERVIEW_POSITION = (() => {
|
||||
const view = galacticToEquatorial({ x: -105, y: -230, z: 175 });
|
||||
return new THREE.Vector3(view.x, view.y, view.z);
|
||||
})();
|
||||
const GALAXY_OVERVIEW_TARGET = new THREE.Vector3(0, 0, 0);
|
||||
const GALAXY_NEAR_PC = 0.01;
|
||||
const GALAXY_FAR_PC = 5000;
|
||||
const GALAXY_MIN_DISTANCE_PC = 0.5;
|
||||
const GALAXY_MAX_DISTANCE_PC = 2000;
|
||||
/** Far enough out to hold the whole Galaxy in frame; the near/far planes swap to match. */
|
||||
const GALAXY_MAX_DISTANCE_PC = 70000;
|
||||
/** How close (pc) the camera dives toward a selected star before the unit-space swap. */
|
||||
const GALAXY_APPROACH_DISTANCE_PC = 0.05;
|
||||
|
||||
/**
|
||||
* Depth range for the galactic scale. The local view needs a 1-centimetre-of-a-parsec near
|
||||
* plane to fly into a star; the galactic view needs a far plane a hundred thousand parsecs out.
|
||||
* Asking one projection to span both would leave the depth buffer with nothing left to
|
||||
* distinguish two arms with. They swap at the crossfade instead, which happens while the camera
|
||||
* is hundreds of parsecs from anything and so is invisible.
|
||||
*/
|
||||
const GALACTIC_NEAR_PC = 5;
|
||||
const GALACTIC_FAR_PC = 250000;
|
||||
|
||||
/** Rings for the local grid (parsecs from the Sun), with the catalogue's edge called out. */
|
||||
const LOCAL_GRID_RINGS_PC = [50, 100, 150, 200, 250];
|
||||
const LOCAL_GRID_SPOKES = 12;
|
||||
/** Rings for the galactic grid (parsecs from the centre), with the Sun's orbit called out. */
|
||||
const GALACTIC_GRID_RINGS_PC = [2500, 5000, SUN_GALACTOCENTRIC_RADIUS_PC, 11000, 14000];
|
||||
const GALACTIC_GRID_SPOKES = 24;
|
||||
/** The local grid passes through the Sun, which is the origin, so tethers drop to height zero. */
|
||||
const LOCAL_PLANE_HEIGHT_PC = 0;
|
||||
/**
|
||||
* How many stars get a permanent drop line to the local grid, and which ones: the brightest in
|
||||
* the catalogue rather than the Sun's nearest neighbours.
|
||||
*
|
||||
* Nearest-to-the-Sun was the right set when the catalogue stopped at 50 pc and the camera sat
|
||||
* just outside it. Across 250 pc those same stars are a speck at the centre, while the brightest
|
||||
* are spread through the whole volume — and are the ones the eye is already on.
|
||||
*/
|
||||
const TETHERED_STAR_COUNT = 60;
|
||||
|
||||
/** Camera pose for the whole-Galaxy overview: above the disc, out past the Sun, looking in. */
|
||||
const GALACTIC_OVERVIEW_HEIGHT_PC = 26000;
|
||||
const GALACTIC_OVERVIEW_BACK_PC = 11000;
|
||||
|
||||
/** Above this share of the Galaxy-model crossfade, the HUD calls the view galactic. */
|
||||
const GALACTIC_LEVEL_THRESHOLD = 0.5;
|
||||
|
||||
const SYSTEM_NEAR_AU = 0.002;
|
||||
const SYSTEM_FAR_AU = 20000;
|
||||
const SYSTEM_MIN_DISTANCE_AU = 0.05;
|
||||
@@ -47,15 +118,34 @@ const SYSTEM_MAX_DISTANCE_AU = 5000;
|
||||
const SYSTEM_ENTRY_DISTANCE_AU = 200;
|
||||
/** How far out (AU) the camera flies before swapping back to galaxy/parsec space. */
|
||||
const SYSTEM_EXIT_DISTANCE_AU = 400;
|
||||
const MIN_SYSTEM_FRAMING_DISTANCE_AU = 3;
|
||||
const MAX_SYSTEM_FRAMING_DISTANCE_AU = 80;
|
||||
|
||||
const APPROACH_DURATION_SECONDS = 1.0;
|
||||
const SETTLE_DURATION_SECONDS = 0.9;
|
||||
const EXIT_DURATION_SECONDS = 0.9;
|
||||
const RETURN_DURATION_SECONDS = 1.1;
|
||||
const GALACTIC_FLIGHT_SECONDS = 2.4;
|
||||
|
||||
const STAR_MARKER_RADIUS_AU = 0.2;
|
||||
/** Camera range for the readout panel, in the unit that suits the distance. */
|
||||
function formatParsecs(distancePc: number): string {
|
||||
return distancePc >= 1000 ? `${(distancePc / 1000).toFixed(1)} kpc` : `${distancePc.toFixed(distancePc < 10 ? 2 : 0)} pc`;
|
||||
}
|
||||
|
||||
function formatAu(distanceAu: number): string {
|
||||
return distanceAu >= 100 ? `${distanceAu.toFixed(0)} AU` : `${distanceAu.toFixed(2)} AU`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the camera sits to hold the whole Galaxy: above the disc and back past the Sun, looking
|
||||
* at the centre — near enough to the angle the Galaxy is usually drawn from, and it keeps the
|
||||
* Sun between the camera and the centre so "you are here" stays legible.
|
||||
*/
|
||||
function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector3 } {
|
||||
const centre = galacticCentrePositionPc();
|
||||
const target = new THREE.Vector3(centre.x, centre.y, centre.z);
|
||||
const awayFromCentre = target.clone().negate().normalize();
|
||||
const position = target.clone().add(galacticNormal().multiplyScalar(GALACTIC_OVERVIEW_HEIGHT_PC)).add(awayFromCentre.multiplyScalar(GALACTIC_OVERVIEW_BACK_PC));
|
||||
return { position, target };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts the shared galaxy + system scene: pan/zoom/rotate camera controls, click-to-select
|
||||
@@ -67,22 +157,21 @@ const STAR_MARKER_RADIUS_AU = 0.2;
|
||||
@Component({
|
||||
selector: 'app-galaxy-system-scene',
|
||||
providers: [EngineService],
|
||||
imports: [StarmapHudComponent],
|
||||
template: `
|
||||
<div class="relative h-full w-full">
|
||||
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
|
||||
<div #labelHost class="absolute inset-0 overflow-hidden pointer-events-none"></div>
|
||||
@if (navigationStore.viewLevel() === 'system') {
|
||||
<button
|
||||
type="button"
|
||||
(click)="exitSystem()"
|
||||
class="absolute top-4 left-4 flex items-center gap-1.5 rounded-md border border-border bg-panel/70 px-3 py-1.5 font-body text-xs tracking-wide text-muted uppercase backdrop-blur-md transition-colors hover:border-accent hover:text-accent focus:outline-none focus:ring-1 focus:ring-accent/50"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 6l-6 6 6 6" />
|
||||
</svg>
|
||||
Galaxy
|
||||
</button>
|
||||
}
|
||||
<app-starmap-hud
|
||||
[level]="navigationStore.viewLevel()"
|
||||
[eyebrow]="hudEyebrow()"
|
||||
[title]="hudTitle()"
|
||||
[subtitle]="hudSubtitle()"
|
||||
[readouts]="hudReadouts()"
|
||||
[note]="hudNote()"
|
||||
[range]="hudRange()"
|
||||
(levelSelected)="goToLevel($event)"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
@@ -94,11 +183,31 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly galaxyGroup = new THREE.Group();
|
||||
private readonly systemGroup = new THREE.Group();
|
||||
private readonly starMarkerMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||||
private readonly starMarkerGeometry = new THREE.SphereGeometry(STAR_MARKER_RADIUS_AU, 24, 16);
|
||||
/** Rebuilt per system, since the star's radius is derived from that system's innermost orbit. */
|
||||
private starMarkerGeometry?: THREE.SphereGeometry;
|
||||
|
||||
/** Readout panel contents, refreshed on the same cadence as the labels rather than per frame. */
|
||||
readonly hudEyebrow = signal('');
|
||||
readonly hudTitle = signal('');
|
||||
readonly hudSubtitle = signal('');
|
||||
readonly hudReadouts = signal<readonly HudReadout[]>([]);
|
||||
readonly hudNote = signal('');
|
||||
readonly hudRange = signal('');
|
||||
|
||||
private controls?: OrbitControls;
|
||||
private rig?: CameraRigController;
|
||||
private starField?: StarFieldRenderer;
|
||||
private deepSky?: DeepSkyRenderer;
|
||||
private deepSkyLabels: readonly LabeledPoint[] = [];
|
||||
/** Stars with at least one catalogued body, which are the ones the map can be flown into. */
|
||||
private starIdsWithBodies = new Set<number>();
|
||||
private milkyWay?: MilkyWayRenderer;
|
||||
private galacticLabels: readonly LabeledPoint[] = [];
|
||||
private galacticGrid?: PolarGridPlane;
|
||||
private localGrid?: PolarGridPlane;
|
||||
private tethers?: TetherField;
|
||||
/** Strength of the Galaxy-model crossfade, 0 (local view) to 1 (galactic view). */
|
||||
private galacticStrength = 0;
|
||||
private labelOverlay?: StarLabelOverlay;
|
||||
private stars: readonly StarRecord[] = [];
|
||||
private starsById = new Map<number, StarRecord>();
|
||||
@@ -107,8 +216,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
private resizeObserver?: ResizeObserver;
|
||||
private unsubscribeTick?: () => void;
|
||||
private labelUpdateAccumulator = 0;
|
||||
private pointerDownAt: { x: number; y: number } | null = null;
|
||||
private ready = false;
|
||||
private busy = false;
|
||||
/** Scale the HUD asked for while a system transition was still unwinding. */
|
||||
private pendingLevel: ViewLevel | null = null;
|
||||
|
||||
/** Id of the star whose system is currently shown (or being flown to/from); null = galaxy view. */
|
||||
private currentStarId: number | null = null;
|
||||
@@ -137,20 +249,52 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeTick?.();
|
||||
this.resizeObserver?.disconnect();
|
||||
this.canvasRef().nativeElement.removeEventListener('pointerdown', this.handlePointerDown);
|
||||
this.canvasRef().nativeElement.removeEventListener('click', this.handleClick);
|
||||
this.controls?.dispose();
|
||||
this.starField?.dispose();
|
||||
this.deepSky?.dispose();
|
||||
this.milkyWay?.dispose();
|
||||
this.galacticGrid?.dispose();
|
||||
this.localGrid?.dispose();
|
||||
this.tethers?.dispose();
|
||||
this.labelOverlay?.dispose();
|
||||
this.systemRenderer?.dispose();
|
||||
(this.starMarker?.material as THREE.Material | undefined)?.dispose();
|
||||
(this.starGlow?.material as THREE.SpriteMaterial | undefined)?.dispose();
|
||||
this.starMarkerGeometry.dispose();
|
||||
this.starMarkerGeometry?.dispose();
|
||||
this.starMarkerMaterial.dispose();
|
||||
this.engine.dispose();
|
||||
}
|
||||
|
||||
exitSystem(): void {
|
||||
this.navigationStore.selectStar(null);
|
||||
/**
|
||||
* Moves the view to a wider scale, from the HUD's scale ladder.
|
||||
*
|
||||
* The two outer levels are one continuous space, so "go to the Milky Way" is a camera flight
|
||||
* rather than a scene change. Leaving a system is not: it has to unwind the unit-space swap
|
||||
* first, so a request made from inside a system is parked until the exit flight lands.
|
||||
*/
|
||||
goToLevel(level: ViewLevel): void {
|
||||
if (level === 'system') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentStarId !== null || this.busy) {
|
||||
this.pendingLevel = level;
|
||||
this.navigationStore.selectStar(null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.flyToOverview(level);
|
||||
}
|
||||
|
||||
private flyToOverview(level: ViewLevel): void {
|
||||
if (!this.rig) {
|
||||
return;
|
||||
}
|
||||
const pose = level === 'galactic' ? galacticOverviewPose() : { position: GALAXY_OVERVIEW_POSITION.clone(), target: GALAXY_OVERVIEW_TARGET.clone() };
|
||||
// The galactic flight covers four orders of magnitude, so it gets longer than a local hop.
|
||||
this.rig.flyTo(pose, level === 'galactic' ? GALACTIC_FLIGHT_SECONDS : RETURN_DURATION_SECONDS);
|
||||
}
|
||||
|
||||
private async bootstrap(): Promise<void> {
|
||||
@@ -182,25 +326,68 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.systemGroup.visible = false;
|
||||
applyMilkyWaySkybox(scene, MILKY_WAY_SKYBOX_PATH);
|
||||
|
||||
const [{ stars, positions }, bodies, exoplanets] = await Promise.all([
|
||||
const [{ stars, positions }, bodies, exoplanets, deepSky] = await Promise.all([
|
||||
this.dataLoader.loadStars(),
|
||||
this.dataLoader.loadBodies(),
|
||||
this.dataLoader.loadExoplanets()
|
||||
this.dataLoader.loadExoplanets(),
|
||||
// The backdrop is decorative — if its dataset is missing or malformed the star field
|
||||
// should still come up, so this one failure is swallowed rather than aborting bootstrap.
|
||||
this.dataLoader.loadDeepSky().catch((error) => {
|
||||
console.error('Failed to load the deep-sky backdrop; continuing without it.', error);
|
||||
return [] as DeepSkyRecord[];
|
||||
})
|
||||
]);
|
||||
this.stars = stars;
|
||||
this.starsById = new Map(stars.map((star) => [star.id, star]));
|
||||
this.bodies = bodies;
|
||||
this.exoplanets = exoplanets;
|
||||
// Built once rather than per label refresh: it is a scan of every body and exoplanet, and the
|
||||
// labels are recomputed whenever the camera moves.
|
||||
this.starIdsWithBodies = new Set([...bodies.map((body) => body.systemStarId), ...exoplanets.map((exoplanet) => exoplanet.hostStarId)].filter(
|
||||
(id): id is number => id !== null && id !== undefined
|
||||
));
|
||||
|
||||
this.starField = new StarFieldRenderer(stars, positions);
|
||||
this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search));
|
||||
this.galaxyGroup.add(this.starField.object);
|
||||
|
||||
this.milkyWay = new MilkyWayRenderer();
|
||||
this.galacticLabels = this.milkyWay.labelPoints();
|
||||
const centre = galacticCentrePositionPc();
|
||||
this.galacticGrid = new PolarGridPlane({
|
||||
ringRadii: GALACTIC_GRID_RINGS_PC,
|
||||
spokeCount: GALACTIC_GRID_SPOKES,
|
||||
centre: new THREE.Vector3(centre.x, centre.y, centre.z),
|
||||
emphasisRadii: [SUN_GALACTOCENTRIC_RADIUS_PC]
|
||||
});
|
||||
this.localGrid = new PolarGridPlane({
|
||||
ringRadii: LOCAL_GRID_RINGS_PC,
|
||||
spokeCount: LOCAL_GRID_SPOKES,
|
||||
emphasisRadii: [LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]]
|
||||
});
|
||||
// A fixed set rather than whatever is currently labelled: a tether that appears and vanishes
|
||||
// as the camera drifts reads as a glitch.
|
||||
this.tethers = new TetherField(TETHERED_STAR_COUNT);
|
||||
this.tethers.setTargets(
|
||||
[...stars]
|
||||
.sort((a, b) => a.magnitude - b.magnitude)
|
||||
.slice(0, TETHERED_STAR_COUNT)
|
||||
.map((star) => new THREE.Vector3(star.x, star.y, star.z)),
|
||||
LOCAL_PLANE_HEIGHT_PC
|
||||
);
|
||||
this.galaxyGroup.add(this.milkyWay.object, this.galacticGrid.object, this.localGrid.object, this.tethers.object);
|
||||
|
||||
if (deepSky.length > 0) {
|
||||
this.deepSky = new DeepSkyRenderer(deepSky);
|
||||
this.galaxyGroup.add(this.deepSky.object);
|
||||
this.deepSkyLabels = this.deepSky.labelPoints(DEEP_SKY_LABEL_COUNT);
|
||||
}
|
||||
|
||||
this.labelOverlay = new StarLabelOverlay(scene);
|
||||
this.labelHostRef().nativeElement.appendChild(this.labelOverlay.domElement);
|
||||
const { width, height } = canvas.getBoundingClientRect();
|
||||
this.labelOverlay.setSize(width, height);
|
||||
|
||||
this.raycaster.params.Points!.threshold = PICK_THRESHOLD_PC;
|
||||
canvas.addEventListener('pointerdown', this.handlePointerDown);
|
||||
canvas.addEventListener('click', this.handleClick);
|
||||
this.observeResize(canvas);
|
||||
|
||||
@@ -215,22 +402,91 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.rig?.update(deltaSeconds);
|
||||
this.controls?.update();
|
||||
|
||||
if (this.currentStarId === null) {
|
||||
this.labelUpdateAccumulator += deltaSeconds;
|
||||
if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) {
|
||||
this.labelUpdateAccumulator = 0;
|
||||
this.updateLabels(camera);
|
||||
}
|
||||
// Gated on the galaxy group rather than on `currentStarId`, which is only assigned once the
|
||||
// arrival flight finishes. In between, the scene has already swapped to system space while
|
||||
// `currentStarId` is still null, so labels were being recomputed from galaxy-scale positions
|
||||
// and pinned over the system — the whole point of clearing them on the swap.
|
||||
if (this.galaxyGroup.visible) {
|
||||
// Per-frame, unlike the labels: this is a handful of uniform writes, and it is what keeps
|
||||
// the zoom continuous rather than stepping between two discrete scales.
|
||||
this.updateGalacticCrossfade(camera);
|
||||
}
|
||||
|
||||
this.systemRenderer?.update(dateToJulianDate());
|
||||
this.labelUpdateAccumulator += deltaSeconds;
|
||||
if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) {
|
||||
this.labelUpdateAccumulator = 0;
|
||||
if (this.galaxyGroup.visible) {
|
||||
this.updateLabels(camera);
|
||||
} else if (this.systemGroup.visible) {
|
||||
this.updateSystemLabels(camera);
|
||||
}
|
||||
this.updateHud(camera);
|
||||
}
|
||||
|
||||
if (this.systemGroup.visible) {
|
||||
this.systemRenderer?.update(dateToJulianDate());
|
||||
}
|
||||
this.labelOverlay?.render(camera);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blends between the two things that share parsec space: the catalogued star field with its
|
||||
* local grid, and the Milky Way model with its galactic one. Driven by how far the camera
|
||||
* has pulled back from the Sun, so the scale ladder reports where the view already is instead
|
||||
* of switching it.
|
||||
*/
|
||||
private updateGalacticCrossfade(camera: THREE.PerspectiveCamera): void {
|
||||
if (!this.milkyWay) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distancePc = camera.position.length();
|
||||
this.galacticStrength = this.milkyWay.setViewerDistancePc(distancePc);
|
||||
|
||||
this.galacticGrid?.setStrength(this.galacticStrength);
|
||||
this.localGrid?.setStrength(1 - this.galacticStrength);
|
||||
this.tethers?.setStrength(1 - this.galacticStrength);
|
||||
// The backdrop shell is the sky as seen from here; from outside it, it is a wall.
|
||||
this.deepSky?.setStrength(1 - this.galacticStrength);
|
||||
// Same argument for the skybox, and more sharply: it is a photograph of the Milky Way taken
|
||||
// from inside it, so it cannot also be the sky behind a view of the Galaxy from outside.
|
||||
this.engine.getScene().backgroundIntensity = 1 - this.galacticStrength;
|
||||
|
||||
this.applyGalaxyDepthRange(camera, distancePc);
|
||||
const level: ViewLevel = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD ? 'galactic' : 'galaxy';
|
||||
if (this.navigationStore.viewLevel() !== level && !this.systemGroup.visible) {
|
||||
this.navigationStore.setViewLevel(level);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the depth range proportional to how far out the camera is. One fixed pair cannot serve
|
||||
* both ends of this view: flying into a star needs a near plane a hundredth of a parsec out,
|
||||
* and holding the Galaxy needs a far plane a hundred thousand parsecs out, and a projection
|
||||
* spanning both has no precision left to separate one spiral arm from the next.
|
||||
*/
|
||||
private applyGalaxyDepthRange(camera: THREE.PerspectiveCamera, distancePc: number): void {
|
||||
const near = THREE.MathUtils.clamp(distancePc / 2000, GALAXY_NEAR_PC, GALACTIC_NEAR_PC);
|
||||
const far = THREE.MathUtils.clamp(distancePc * 8, GALAXY_FAR_PC, GALACTIC_FAR_PC);
|
||||
// Only when it has drifted enough to matter, so a slow zoom isn't rebuilding the projection
|
||||
// matrix on every frame of it.
|
||||
if (Math.abs(near - camera.near) > camera.near * 0.05 || Math.abs(far - camera.far) > camera.far * 0.05) {
|
||||
camera.near = near;
|
||||
camera.far = far;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
}
|
||||
|
||||
private updateLabels(camera: THREE.PerspectiveCamera): void {
|
||||
const selectedId = this.navigationStore.selectedStarId();
|
||||
const { x: cx, y: cy, z: cz } = camera.position;
|
||||
const maxDistanceSq = LABEL_MAX_DISTANCE_PC * LABEL_MAX_DISTANCE_PC;
|
||||
// Measured from what the camera is looking at, not from where it is. Those differ by the
|
||||
// orbit distance, so a camera-relative rule names the stars closest to the near edge of the
|
||||
// view — a ring of labels around the outside of the thing the user is actually looking at.
|
||||
const target = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
|
||||
const { x: cx, y: cy, z: cz } = target;
|
||||
const orbitDistance = (this.controls ? camera.position.distanceTo(target) : GALAXY_OVERVIEW_POSITION.length()) * LABEL_RADIUS_TO_ORBIT_DISTANCE;
|
||||
const labelRadius = THREE.MathUtils.clamp(orbitDistance, MIN_LABEL_RADIUS_PC, MAX_LABEL_RADIUS_PC);
|
||||
const maxDistanceSq = labelRadius * labelRadius;
|
||||
|
||||
const candidates: Array<{ star: StarRecord; distanceSq: number }> = [];
|
||||
for (const star of this.stars) {
|
||||
@@ -243,15 +499,196 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => a.distanceSq - b.distanceSq);
|
||||
this.labelOverlay?.update(candidates.slice(0, LABEL_MAX_COUNT).map((candidate) => candidate.star));
|
||||
// Brightest first, not nearest first. Proximity was the right ranking when the catalogue was
|
||||
// a 50 pc bubble and everything in it was equally worth naming; across 250 pc it labels a
|
||||
// clump of whatever happens to be closest to the middle of the screen and never names the
|
||||
// stars that are actually prominent. Brightness is what makes a star worth a name.
|
||||
candidates.sort((a, b) => a.star.magnitude - b.star.magnitude);
|
||||
// Individual star names mean nothing once the whole Galaxy is in frame — at that range the
|
||||
// entire catalogue is inside one pixel — so the labels hand over to the structural ones.
|
||||
const isGalactic = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD;
|
||||
// "System" rather than "Star" for anything with catalogued bodies: it is the one distinction
|
||||
// the second line can draw that the map cannot otherwise show, since it says which of these
|
||||
// points is somewhere you can actually go.
|
||||
const starLabels: LabeledPoint[] = isGalactic
|
||||
? []
|
||||
: this.spreadLabels(
|
||||
candidates.map(({ star }) => ({
|
||||
id: star.id,
|
||||
name: star.name,
|
||||
kind: this.starIdsWithBodies.has(star.id) ? 'System' : 'Star',
|
||||
x: star.x,
|
||||
y: star.y,
|
||||
z: star.z
|
||||
})),
|
||||
camera,
|
||||
selectedId
|
||||
);
|
||||
const backdropLabels = isGalactic ? this.galacticLabels : this.deepSkyLabels;
|
||||
this.labelOverlay?.update([...starLabels, ...backdropLabels]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes candidate labels in priority order and keeps only those that land clear of the labels
|
||||
* already placed, dropping the rest.
|
||||
*
|
||||
* Priority alone is not enough at either scale. The Sun's fifteen nearest neighbours are all
|
||||
* inside four parsecs, so from anything but point-blank range their names print on top of each
|
||||
* other in a single unreadable clump; the inner four planets do exactly the same thing when a
|
||||
* system is framed out to Pluto. Rejecting on screen separation rather than on distance means
|
||||
* the set naturally opens up as the camera closes in, and stays legible when it pulls back.
|
||||
*
|
||||
* `keepId` is exempt from both tests — it is the selection, which is about to be flown to, and
|
||||
* its label going missing mid-flight reads as the target having been lost.
|
||||
*/
|
||||
private spreadLabels(candidates: readonly LabeledPoint[], camera: THREE.PerspectiveCamera, keepId: number | string | null): LabeledPoint[] {
|
||||
const placed: THREE.Vector2[] = [];
|
||||
const chosen: LabeledPoint[] = [];
|
||||
const projected = new THREE.Vector3();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (chosen.length >= LABEL_MAX_COUNT) {
|
||||
break;
|
||||
}
|
||||
|
||||
projected.set(candidate.x, candidate.y, candidate.z).project(camera);
|
||||
const isKept = candidate.id === keepId;
|
||||
// Offscreen or behind the camera.
|
||||
if (!isKept && (projected.z < -1 || projected.z > 1 || Math.abs(projected.x) > 1 || Math.abs(projected.y) > 1)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const point = new THREE.Vector2(projected.x * camera.aspect, projected.y);
|
||||
if (!isKept && placed.some((other) => other.distanceTo(point) < LABEL_MIN_SEPARATION_NDC)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
placed.push(point);
|
||||
chosen.push(candidate);
|
||||
}
|
||||
|
||||
return chosen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the bodies of the system the view is inside.
|
||||
*
|
||||
* Outermost first, because that is the order that survives the separation test usefully: with
|
||||
* the whole system in frame the outer planets are the ones far enough apart to label, and the
|
||||
* inner four are a single clump around the star. Closing in reverses it on its own — the outer
|
||||
* orbits leave the frame and their labels drop out, freeing the space for the inner planets.
|
||||
*
|
||||
* Moons are left out entirely: they sit within a marker's width of their planet at system
|
||||
* framing, so their labels could only ever print on top of it.
|
||||
*/
|
||||
private updateSystemLabels(camera: THREE.PerspectiveCamera): void {
|
||||
const renderer = this.systemRenderer;
|
||||
if (!renderer) {
|
||||
this.labelOverlay?.update([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const records = new Map<string, { name: string; semiMajorAxisAu: number }>([
|
||||
...this.bodies.map((body): [string, { name: string; semiMajorAxisAu: number }] => [
|
||||
body.id,
|
||||
{ name: body.name, semiMajorAxisAu: body.orbit.semiMajorAxisAu }
|
||||
]),
|
||||
...this.exoplanets.map((exoplanet): [string, { name: string; semiMajorAxisAu: number }] => [
|
||||
exoplanet.id,
|
||||
{ name: exoplanet.name, semiMajorAxisAu: exoplanet.orbit?.semiMajorAxisAu ?? 0 }
|
||||
])
|
||||
]);
|
||||
const position = new THREE.Vector3();
|
||||
|
||||
const points: Array<LabeledPoint & { semiMajorAxisAu: number }> = [];
|
||||
for (const member of renderer.members) {
|
||||
if (member.kind === 'moon') {
|
||||
continue;
|
||||
}
|
||||
const record = records.get(member.id);
|
||||
member.marker.getWorldPosition(position);
|
||||
points.push({
|
||||
id: member.id,
|
||||
name: record?.name ?? member.id,
|
||||
kind: member.kind === 'exoplanet' ? 'Exoplanet' : member.kind === 'dwarf' ? 'Dwarf Planet' : 'Planet',
|
||||
semiMajorAxisAu: record?.semiMajorAxisAu ?? 0,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
z: position.z
|
||||
});
|
||||
}
|
||||
|
||||
points.sort((a, b) => b.semiMajorAxisAu - a.semiMajorAxisAu);
|
||||
this.labelOverlay?.update(this.spreadLabels(points, camera, null));
|
||||
}
|
||||
|
||||
/** Refreshes the readout panel for whichever scale the view is currently at. */
|
||||
private updateHud(camera: THREE.PerspectiveCamera): void {
|
||||
const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId);
|
||||
|
||||
if (this.systemGroup.visible && star) {
|
||||
const planetCount = this.bodies.filter((body) => body.systemStarId === star.id && !body.parentBodyId).length + this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id).length;
|
||||
this.hudEyebrow.set('System');
|
||||
this.hudTitle.set(star.name);
|
||||
this.hudSubtitle.set(star.spectralType ? `Spectral type ${star.spectralType}` : '');
|
||||
this.hudReadouts.set([
|
||||
{ label: 'Bodies', value: `${planetCount}` },
|
||||
{ label: 'Distance', value: `${Math.hypot(star.x, star.y, star.z).toFixed(2)} pc` },
|
||||
{ label: 'Magnitude', value: star.magnitude.toFixed(2) }
|
||||
]);
|
||||
this.hudNote.set('Orbits propagated from published elements to the current date.');
|
||||
this.hudRange.set(formatAu(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET)));
|
||||
return;
|
||||
}
|
||||
|
||||
this.hudRange.set(formatParsecs(camera.position.length()));
|
||||
|
||||
if (this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) {
|
||||
this.hudEyebrow.set('Galactic Scale');
|
||||
this.hudTitle.set('Milky Way');
|
||||
this.hudSubtitle.set('Barred spiral galaxy · our own');
|
||||
this.hudReadouts.set([
|
||||
{ label: 'Sun to centre', value: `${(SUN_GALACTOCENTRIC_RADIUS_PC / 1000).toFixed(2)} kpc` },
|
||||
{ label: 'Arms modelled', value: `${MILKY_WAY_ARMS.length}` },
|
||||
{ label: 'Catalogued', value: `${this.stars.length} stars` }
|
||||
]);
|
||||
// Quotes the catalogue's own reach rather than a figure that has already been raised once.
|
||||
this.hudNote.set(`Galactic structure is an illustrative model built on measured arm geometry — no catalogue holds the Galaxy’s stars. The ${this.stars.length} catalogued stars within ${LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]} pc are real.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.hudEyebrow.set('Solar Neighbourhood');
|
||||
this.hudTitle.set('Local Stars');
|
||||
this.hudSubtitle.set('Hipparcos · Yale Bright Star · Gliese');
|
||||
this.hudReadouts.set([
|
||||
// Both numbers, because they differ: the catalogue is what the map knows and the first is
|
||||
// what it draws. See `STAR_RENDER_BUDGET`.
|
||||
{ label: 'Stars', value: this.starField && this.starField.drawnCount < this.stars.length ? `${this.starField.drawnCount} / ${this.stars.length}` : `${this.stars.length}` },
|
||||
{ label: 'Radius', value: `${LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]} pc` },
|
||||
{ label: 'Exoplanets', value: `${this.exoplanets.length}` }
|
||||
]);
|
||||
this.hudNote.set('Positions from measured parallaxes. Grid marks the galactic plane through the Sun.');
|
||||
}
|
||||
|
||||
/** Where the current press started, so a drag can be told apart from a click. */
|
||||
private readonly handlePointerDown = (event: PointerEvent): void => {
|
||||
this.pointerDownAt = { x: event.clientX, y: event.clientY };
|
||||
};
|
||||
|
||||
private readonly handleClick = (event: MouseEvent): void => {
|
||||
if (this.rig?.isAnimating) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The browser fires `click` on release however far the pointer travelled, and OrbitControls
|
||||
// does not suppress it — so without this every drag-to-rotate that happens to finish over a
|
||||
// star would launch a camera flight into its system.
|
||||
const pressedAt = this.pointerDownAt;
|
||||
this.pointerDownAt = null;
|
||||
if (pressedAt && Math.hypot(event.clientX - pressedAt.x, event.clientY - pressedAt.y) > CLICK_DRAG_SLOP_PX) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = this.canvasRef().nativeElement;
|
||||
const camera = this.engine.getCamera();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
@@ -259,18 +696,19 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.raycaster.setFromCamera(pointerNdc, camera);
|
||||
|
||||
if (this.currentStarId === null) {
|
||||
this.handleGalaxyClick();
|
||||
this.handleGalaxyClick(pointerNdc, camera);
|
||||
} else {
|
||||
this.handleSystemClick();
|
||||
}
|
||||
};
|
||||
|
||||
private handleGalaxyClick(): void {
|
||||
private handleGalaxyClick(pointerNdc: THREE.Vector2, camera: THREE.PerspectiveCamera): void {
|
||||
if (!this.starField) {
|
||||
return;
|
||||
}
|
||||
const [hit] = this.raycaster.intersectObject(this.starField.object);
|
||||
const starId = hit?.index !== undefined ? this.starField.starIdAt(hit.index) : undefined;
|
||||
// Screen-space rather than a raycast: the star field billboards in the vertex shader, so
|
||||
// its CPU-side geometry is a single quad at the origin. See `StarFieldRenderer.pickAt`.
|
||||
const starId = this.starField.pickAt(pointerNdc, camera);
|
||||
if (starId !== undefined) {
|
||||
this.navigationStore.selectStar(starId);
|
||||
}
|
||||
@@ -309,6 +747,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
private finishTransition(): void {
|
||||
this.busy = false;
|
||||
this.reconcileSelection(this.navigationStore.selectedStarId());
|
||||
|
||||
// Only once the scene is settled back in parsec space can a scale request be honoured.
|
||||
const pending = this.pendingLevel;
|
||||
this.pendingLevel = null;
|
||||
if (pending && !this.busy && this.currentStarId === null) {
|
||||
this.flyToOverview(pending);
|
||||
}
|
||||
}
|
||||
|
||||
private enterSystem(starId: number, onComplete: () => void): void {
|
||||
@@ -347,26 +792,49 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
const systemBodies = this.bodies.filter((body) => body.systemStarId === star.id);
|
||||
const systemExoplanets = this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id);
|
||||
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets);
|
||||
// The star's own position is the line of sight to it, which is the plane the archive
|
||||
// measures exoplanet inclinations against. The Sun sits at the origin and has no
|
||||
// exoplanets, so it has no meaningful direction and the renderer falls back.
|
||||
// The star's luminosity, derived from its own catalogued magnitude and distance, is what
|
||||
// decides how hot each body in the system is — and so what each of them looks like.
|
||||
const hostLuminosity = luminositySolar({ magnitude: star.magnitude, distancePc: Math.hypot(star.x, star.y, star.z), spectralType: star.spectralType });
|
||||
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity);
|
||||
this.systemGroup.add(this.systemRenderer.object);
|
||||
|
||||
// Framed against the grid's outer ring rather than the outermost orbit — the ring is always
|
||||
// the wider of the two — and against the camera this scene actually has, so the margin holds
|
||||
// whatever the window shape. Computed before the star, because how far away the star will be
|
||||
// seen from is what decides how big its halo has to be to stay visible.
|
||||
const viewport = { fovDegrees: camera.fov, aspect: camera.aspect };
|
||||
const framingDistance = systemFramingDistanceAu(this.systemRenderer.gridOuterRadiusAu, viewport);
|
||||
const frameRadiusAu = systemFrameRadiusAu(framingDistance, viewport);
|
||||
|
||||
// Sized against this system's innermost orbit, so the star never swallows its own planets.
|
||||
const starRadiusAu = starMarkerRadiusAu(this.systemRenderer.minTopLevelSemiMajorAxisAu);
|
||||
this.starMarkerGeometry?.dispose();
|
||||
this.starMarkerGeometry = new THREE.SphereGeometry(starRadiusAu, 24, 16);
|
||||
|
||||
const starMarkerMaterial = this.starMarkerMaterial.clone();
|
||||
const starColor = colorIndexToRgb(star.colorIndex);
|
||||
const starColor = colorIndexToRgb(star.colorIndex, star.spectralType);
|
||||
if (star.id === SOL_STAR_ID) {
|
||||
// The Sun is the only star we have (and could ever have) a real photograph of; every
|
||||
// other point in the galaxy view is far too distant to be resolved as a disk.
|
||||
starMarkerMaterial.map = loadCachedTexture(SUN_TEXTURE_PATH);
|
||||
starMarkerMaterial.color.set(0xffffff);
|
||||
this.starGlow = createGlowSprite(0xfff2c0, STAR_MARKER_RADIUS_AU, SUN_GLOW_SCALE);
|
||||
this.starGlow = createGlowSprite(0xfff2c0, starGlowExtentAu(starRadiusAu, frameRadiusAu));
|
||||
} else {
|
||||
starMarkerMaterial.color.copy(starColor);
|
||||
this.starGlow = createGlowSprite(starColor, STAR_MARKER_RADIUS_AU, SUN_GLOW_SCALE * 0.6);
|
||||
this.starGlow = createGlowSprite(starColor, starGlowExtentAu(starRadiusAu, frameRadiusAu, DIM_STAR_GLOW_SCALE));
|
||||
}
|
||||
this.starMarker = new THREE.Mesh(this.starMarkerGeometry, starMarkerMaterial);
|
||||
this.systemGroup.add(this.starMarker, this.starGlow);
|
||||
|
||||
this.galaxyGroup.visible = false;
|
||||
this.systemGroup.visible = true;
|
||||
// Labels are CSS2D objects parented to the scene, not to galaxyGroup, so hiding the group
|
||||
// does not hide them: without this the galaxy-scale star names stay pinned on screen,
|
||||
// clumped over the system's star.
|
||||
this.labelOverlay?.update([]);
|
||||
|
||||
camera.near = SYSTEM_NEAR_AU;
|
||||
camera.far = SYSTEM_FAR_AU;
|
||||
@@ -376,13 +844,12 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
this.rig!.setImmediate({ position: direction.clone().multiplyScalar(SYSTEM_ENTRY_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) });
|
||||
|
||||
const framingDistance = THREE.MathUtils.clamp(
|
||||
this.systemRenderer.maxTopLevelSemiMajorAxisAu * 2.4 || MIN_SYSTEM_FRAMING_DISTANCE_AU,
|
||||
MIN_SYSTEM_FRAMING_DISTANCE_AU,
|
||||
MAX_SYSTEM_FRAMING_DISTANCE_AU
|
||||
);
|
||||
// Arrives along whichever direction the approach came from, then swings round to look down
|
||||
// on this system's own orbital plane as it settles — so the swap stays continuous but the
|
||||
// system is not presented edge-on. See `systemViewDirection`.
|
||||
const viewDirection = systemViewDirection(this.systemRenderer.referenceFrame);
|
||||
|
||||
this.rig!.flyTo({ position: direction.clone().multiplyScalar(framingDistance), target: new THREE.Vector3(0, 0, 0) }, SETTLE_DURATION_SECONDS, () => {
|
||||
this.rig!.flyTo({ position: viewDirection.multiplyScalar(framingDistance), target: new THREE.Vector3(0, 0, 0) }, SETTLE_DURATION_SECONDS, () => {
|
||||
this.currentStarId = star.id;
|
||||
this.navigationStore.setViewLevel('system');
|
||||
onComplete();
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { galacticCentrePositionPc, SUN_HEIGHT_ABOVE_MIDPLANE_PC } from '../../shared/astro/galaxy';
|
||||
import { galacticFrameQuaternion, galacticNormal, PolarGridPlane, TetherField } from './grid-plane';
|
||||
|
||||
const SEGMENTS_PER_RING = 180;
|
||||
|
||||
function vertexAt(geometry: THREE.BufferGeometry, index: number): THREE.Vector3 {
|
||||
const position = geometry.getAttribute('position');
|
||||
return new THREE.Vector3(position.getX(index), position.getY(index), position.getZ(index));
|
||||
}
|
||||
|
||||
describe('galacticFrameQuaternion', () => {
|
||||
it('carries the local +Z onto the galactic normal, so a flat grid lands in the galactic plane', () => {
|
||||
const rotated = new THREE.Vector3(0, 0, 1).applyQuaternion(galacticFrameQuaternion());
|
||||
const normal = galacticNormal();
|
||||
expect(rotated.x).toBeCloseTo(normal.x, 9);
|
||||
expect(rotated.y).toBeCloseTo(normal.y, 9);
|
||||
expect(rotated.z).toBeCloseTo(normal.z, 9);
|
||||
});
|
||||
|
||||
it('tilts that plane the real angle away from the celestial equator', () => {
|
||||
// The galactic and celestial poles are 62.9 degrees apart, so the planes are too.
|
||||
const normal = galacticNormal();
|
||||
expect((Math.acos(Math.abs(normal.z)) * 180) / Math.PI).toBeCloseTo(62.87, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PolarGridPlane', () => {
|
||||
const rings = [10, 20, 50];
|
||||
const spokes = 8;
|
||||
const grid = new PolarGridPlane({ ringRadii: rings, spokeCount: spokes, emphasisRadii: [50] });
|
||||
|
||||
it('draws every ring segment and every spoke', () => {
|
||||
expect(grid.object.geometry.getAttribute('position').count).toBe(rings.length * SEGMENTS_PER_RING * 2 + spokes * 2);
|
||||
});
|
||||
|
||||
it('starts hidden, so a view that never zooms out never draws it', () => {
|
||||
expect(grid.object.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('fades in and out with strength, and disappears outright at zero', () => {
|
||||
grid.setStrength(1);
|
||||
expect(grid.object.visible).toBe(true);
|
||||
const full = (grid.object.material as THREE.LineBasicMaterial).opacity;
|
||||
|
||||
grid.setStrength(0.5);
|
||||
expect((grid.object.material as THREE.LineBasicMaterial).opacity).toBeCloseTo(full / 2, 6);
|
||||
|
||||
grid.setStrength(0);
|
||||
expect(grid.object.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('clamps strength rather than letting opacity run past one', () => {
|
||||
grid.setStrength(4);
|
||||
expect((grid.object.material as THREE.LineBasicMaterial).opacity).toBeLessThanOrEqual(1);
|
||||
grid.setStrength(-1);
|
||||
expect(grid.object.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('lies in the galactic plane through its centre once placed in the scene', () => {
|
||||
grid.object.updateMatrixWorld(true);
|
||||
const normal = galacticNormal();
|
||||
|
||||
for (const index of [0, 100, 1000, grid.object.geometry.getAttribute('position').count - 1]) {
|
||||
const world = vertexAt(grid.object.geometry, index).applyMatrix4(grid.object.matrixWorld);
|
||||
expect(world.dot(normal)).toBeCloseTo(0, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('sits on the galactic centre when given it, still in the plane', () => {
|
||||
const centre = galacticCentrePositionPc();
|
||||
const galacticGrid = new PolarGridPlane({
|
||||
ringRadii: [2500, 8178],
|
||||
spokeCount: 4,
|
||||
centre: new THREE.Vector3(centre.x, centre.y, centre.z)
|
||||
});
|
||||
galacticGrid.object.updateMatrixWorld(true);
|
||||
|
||||
const normal = galacticNormal();
|
||||
const world = vertexAt(galacticGrid.object.geometry, 0).applyMatrix4(galacticGrid.object.matrixWorld);
|
||||
// The centre is one Sun-height below the Sun's own plane, and the grid follows it there.
|
||||
expect(world.dot(normal)).toBeCloseTo(-SUN_HEIGHT_ABOVE_MIDPLANE_PC, 4);
|
||||
|
||||
galacticGrid.dispose();
|
||||
});
|
||||
|
||||
it('lies in whatever plane it is oriented into, for a system read against its own', () => {
|
||||
// The system view passes the frame its orbital elements were measured in, which has nothing
|
||||
// to do with the Galaxy's plane.
|
||||
const orientation = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2);
|
||||
const systemGrid = new PolarGridPlane({ ringRadii: [1, 2, 3], spokeCount: 6, orientation });
|
||||
systemGrid.object.updateMatrixWorld(true);
|
||||
|
||||
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(orientation);
|
||||
for (const index of [0, 200, systemGrid.object.geometry.getAttribute('position').count - 1]) {
|
||||
const world = vertexAt(systemGrid.object.geometry, index).applyMatrix4(systemGrid.object.matrixWorld);
|
||||
expect(world.dot(normal)).toBeCloseTo(0, 6);
|
||||
}
|
||||
// ...and it is genuinely a different plane from the default.
|
||||
expect(Math.abs(normal.dot(galacticNormal()))).toBeLessThan(0.99);
|
||||
|
||||
systemGrid.dispose();
|
||||
});
|
||||
|
||||
it('honours an explicit peak opacity, for a grid that has to sit under other rings', () => {
|
||||
const quiet = new PolarGridPlane({ ringRadii: [1, 2], spokeCount: 4, opacity: 0.2 });
|
||||
quiet.setStrength(1);
|
||||
expect((quiet.object.material as THREE.LineBasicMaterial).opacity).toBeCloseTo(0.2, 6);
|
||||
quiet.dispose();
|
||||
});
|
||||
|
||||
it('keeps the emphasised ring brighter than the rest', () => {
|
||||
const colors = grid.object.geometry.getAttribute('color');
|
||||
// Vertices are written ring by ring, in the order they were listed: 10 pc first, 50 pc last.
|
||||
const innerBrightness = colors.getX(0) + colors.getY(0) + colors.getZ(0);
|
||||
const emphasisIndex = 2 * SEGMENTS_PER_RING * 2;
|
||||
const emphasisBrightness = colors.getX(emphasisIndex) + colors.getY(emphasisIndex) + colors.getZ(emphasisIndex);
|
||||
expect(emphasisBrightness).toBeGreaterThan(innerBrightness);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TetherField', () => {
|
||||
it('drops each point onto the plane, straight down the galactic normal', () => {
|
||||
const field = new TetherField(4);
|
||||
const point = new THREE.Vector3(12, -7, 30);
|
||||
field.setTargets([point]);
|
||||
|
||||
const geometry = field.object.geometry;
|
||||
const top = vertexAt(geometry, 0);
|
||||
const foot = vertexAt(geometry, 1);
|
||||
const normal = galacticNormal();
|
||||
|
||||
expect(top.distanceTo(point)).toBeCloseTo(0, 4);
|
||||
// The foot is in the plane...
|
||||
expect(foot.dot(normal)).toBeCloseTo(0, 4);
|
||||
// ...and directly below the point: the drop has no sideways component.
|
||||
const drop = top.clone().sub(foot);
|
||||
expect(drop.clone().cross(normal).length()).toBeCloseTo(0, 4);
|
||||
|
||||
field.dispose();
|
||||
});
|
||||
|
||||
it('drops onto an offset plane when asked, for a grid on the true midplane', () => {
|
||||
const field = new TetherField(2);
|
||||
field.setTargets([new THREE.Vector3(0, 0, 100)], -SUN_HEIGHT_ABOVE_MIDPLANE_PC);
|
||||
|
||||
const foot = vertexAt(field.object.geometry, 1);
|
||||
expect(foot.dot(galacticNormal())).toBeCloseTo(-SUN_HEIGHT_ABOVE_MIDPLANE_PC, 4);
|
||||
|
||||
field.dispose();
|
||||
});
|
||||
|
||||
it('draws two vertices per tether and nothing for the ones it was not given', () => {
|
||||
const field = new TetherField(8);
|
||||
field.setTargets([new THREE.Vector3(1, 2, 3), new THREE.Vector3(4, 5, 6)]);
|
||||
expect(field.object.geometry.drawRange.count).toBe(4);
|
||||
|
||||
field.setTargets([]);
|
||||
expect(field.object.geometry.drawRange.count).toBe(0);
|
||||
|
||||
field.dispose();
|
||||
});
|
||||
|
||||
it('drops points past its capacity rather than overrunning the buffer', () => {
|
||||
const field = new TetherField(2);
|
||||
const points = [new THREE.Vector3(1, 0, 5), new THREE.Vector3(2, 0, 5), new THREE.Vector3(3, 0, 5), new THREE.Vector3(4, 0, 5)];
|
||||
expect(() => field.setTargets(points)).not.toThrow();
|
||||
expect(field.object.geometry.drawRange.count).toBe(4);
|
||||
expect(field.object.geometry.getAttribute('position').count).toBe(4);
|
||||
|
||||
field.dispose();
|
||||
});
|
||||
|
||||
it('drops down whatever normal it was built with, not always the galactic one', () => {
|
||||
const normal = new THREE.Vector3(0, 1, 0);
|
||||
const field = new TetherField(2, { normal });
|
||||
field.setTargets([new THREE.Vector3(3, 7, 5)]);
|
||||
|
||||
const foot = vertexAt(field.object.geometry, 1);
|
||||
// The foot keeps the in-plane components and loses only the height along the normal.
|
||||
expect(foot.x).toBeCloseTo(3, 6);
|
||||
expect(foot.y).toBeCloseTo(0, 6);
|
||||
expect(foot.z).toBeCloseTo(5, 6);
|
||||
|
||||
field.dispose();
|
||||
});
|
||||
|
||||
it('normalises the normal it is given, so an unnormalised frame axis still lands on the plane', () => {
|
||||
const field = new TetherField(2, { normal: new THREE.Vector3(0, 0, 4) });
|
||||
field.setTargets([new THREE.Vector3(1, 1, 9)]);
|
||||
expect(vertexAt(field.object.geometry, 1).z).toBeCloseTo(0, 6);
|
||||
field.dispose();
|
||||
});
|
||||
|
||||
it('stays hidden until it is given a strength', () => {
|
||||
const field = new TetherField(2);
|
||||
expect(field.object.visible).toBe(false);
|
||||
|
||||
field.setStrength(1);
|
||||
expect(field.object.visible).toBe(true);
|
||||
field.setStrength(0);
|
||||
expect(field.object.visible).toBe(false);
|
||||
|
||||
field.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
|
||||
import { GALACTIC_BASIS_EQUATORIAL, SUN_HEIGHT_ABOVE_MIDPLANE_PC } from '../../shared/astro/galaxy';
|
||||
|
||||
const SEGMENTS_PER_RING = 180;
|
||||
|
||||
/**
|
||||
* The rotation that carries the galactic frame's axes onto the scene's equatorial ones, as a
|
||||
* quaternion — so a grid built flat in XY comes out lying in the galactic plane, tilted the
|
||||
* real 63 degrees against the celestial equator rather than parked on an arbitrary plane.
|
||||
*/
|
||||
export function galacticFrameQuaternion(): THREE.Quaternion {
|
||||
const { x, y, z } = GALACTIC_BASIS_EQUATORIAL;
|
||||
const basis = new THREE.Matrix4().makeBasis(new THREE.Vector3(x.x, x.y, x.z), new THREE.Vector3(y.x, y.y, y.z), new THREE.Vector3(z.x, z.y, z.z));
|
||||
return new THREE.Quaternion().setFromRotationMatrix(basis);
|
||||
}
|
||||
|
||||
/** The galactic plane's unit normal, in the equatorial frame. */
|
||||
export function galacticNormal(): THREE.Vector3 {
|
||||
const { z } = GALACTIC_BASIS_EQUATORIAL;
|
||||
return new THREE.Vector3(z.x, z.y, z.z);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distances here carry no unit of their own: they are whatever the group the grid is added to
|
||||
* works in — parsecs in the galaxy view, AU in the system view.
|
||||
*/
|
||||
export interface PolarGridOptions {
|
||||
/** Ring radii to draw, innermost first. */
|
||||
readonly ringRadii: readonly number[];
|
||||
/** Radial spokes drawn from the innermost to the outermost ring. */
|
||||
readonly spokeCount: number;
|
||||
/**
|
||||
* Rotation from the grid's own XY plane onto the plane it should lie in. Defaults to the
|
||||
* galactic plane; the system view passes the frame its orbital elements were measured in.
|
||||
*/
|
||||
readonly orientation?: THREE.Quaternion;
|
||||
/**
|
||||
* Centre of the grid, which also fixes the plane it lies in. Defaults to the origin — note
|
||||
* that in the galaxy view the origin is the Sun, whose own plane is
|
||||
* {@link SUN_HEIGHT_ABOVE_MIDPLANE_PC} above the Galaxy's midplane; that matters at the local
|
||||
* scale and is invisible at the galactic one.
|
||||
*/
|
||||
readonly centre?: THREE.Vector3;
|
||||
readonly color?: THREE.ColorRepresentation;
|
||||
/** Rings listed here are drawn at full strength — used to call out a meaningful radius. */
|
||||
readonly emphasisRadii?: readonly number[];
|
||||
/** Peak opacity, for a grid that should read louder or quieter than the default. */
|
||||
readonly opacity?: number;
|
||||
/**
|
||||
* Breaks the rings into dashes. Worth it where the grid shares a plane with real curves it
|
||||
* could be mistaken for — the system view draws orbit ellipses in the same plane, and a solid
|
||||
* ring there is indistinguishable at a glance from a circular orbit. Dashed reads as
|
||||
* "reference", solid as "something is actually there".
|
||||
*/
|
||||
readonly dashed?: boolean;
|
||||
}
|
||||
|
||||
/** Ring segments per dash and per gap when {@link PolarGridOptions.dashed} is set. */
|
||||
const DASH_SEGMENTS = 2;
|
||||
|
||||
/**
|
||||
* A polar grid lying in a reference plane: concentric rings and radial spokes, fading out with
|
||||
* radius.
|
||||
*
|
||||
* This is the one piece of chrome that makes a 3D map readable. Without a reference plane a
|
||||
* cloud of points has no depth at all — two stars a thousand parsecs apart look like neighbours,
|
||||
* and a planet above its system's plane looks like one inside it. With a plane under them, and a
|
||||
* tether from each down to it, the eye reads height directly. It is also the signature of the
|
||||
* map this view is modelled on.
|
||||
*/
|
||||
export class PolarGridPlane {
|
||||
readonly object: THREE.LineSegments;
|
||||
|
||||
private readonly geometry = new THREE.BufferGeometry();
|
||||
private readonly material: THREE.LineBasicMaterial;
|
||||
private readonly baseOpacity: number;
|
||||
|
||||
constructor(options: PolarGridOptions) {
|
||||
const color = new THREE.Color(options.color ?? 0x4dd7ff);
|
||||
const emphasis = new Set(options.emphasisRadii ?? []);
|
||||
const outerRadius = Math.max(...options.ringRadii);
|
||||
const innerRadius = Math.min(...options.ringRadii);
|
||||
|
||||
const vertices: number[] = [];
|
||||
const colors: number[] = [];
|
||||
|
||||
const push = (x: number, y: number, brightness: number): void => {
|
||||
vertices.push(x, y, 0);
|
||||
colors.push(color.r * brightness, color.g * brightness, color.b * brightness);
|
||||
};
|
||||
|
||||
for (const radius of options.ringRadii) {
|
||||
// Rings dim toward the edge of the grid so it dissolves into the void instead of ending.
|
||||
const brightness = emphasis.has(radius) ? 1 : 0.55 * (1 - (0.6 * radius) / outerRadius);
|
||||
for (let segment = 0; segment < SEGMENTS_PER_RING; segment++) {
|
||||
// Dashes are cut by dropping whole segments rather than by a dashed material: the ring is
|
||||
// already built from independent segment pairs, so a material's dash pattern would
|
||||
// restart at each one. Skipping segments also keeps the dash angular, so every ring is
|
||||
// dashed at the same rate however large it is.
|
||||
if (options.dashed && segment % (DASH_SEGMENTS * 2) >= DASH_SEGMENTS) {
|
||||
continue;
|
||||
}
|
||||
const a = (segment / SEGMENTS_PER_RING) * Math.PI * 2;
|
||||
const b = ((segment + 1) / SEGMENTS_PER_RING) * Math.PI * 2;
|
||||
push(Math.cos(a) * radius, Math.sin(a) * radius, brightness);
|
||||
push(Math.cos(b) * radius, Math.sin(b) * radius, brightness);
|
||||
}
|
||||
}
|
||||
|
||||
for (let spoke = 0; spoke < options.spokeCount; spoke++) {
|
||||
const angle = (spoke / options.spokeCount) * Math.PI * 2;
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
push(cos * innerRadius, sin * innerRadius, 0.4);
|
||||
push(cos * outerRadius, sin * outerRadius, 0.05);
|
||||
}
|
||||
|
||||
this.geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
|
||||
this.geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
|
||||
|
||||
// Deliberately restrained: the grid is the reference the map is read against, not the map.
|
||||
this.baseOpacity = options.opacity ?? 0.55;
|
||||
this.material = new THREE.LineBasicMaterial({
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
|
||||
this.object = new THREE.LineSegments(this.geometry, this.material);
|
||||
// Built flat in its own XY plane, then rotated onto the reference plane and slid to centre.
|
||||
this.object.quaternion.copy(options.orientation ?? galacticFrameQuaternion());
|
||||
this.object.position.copy(options.centre ?? new THREE.Vector3());
|
||||
this.object.visible = false;
|
||||
this.object.renderOrder = -1;
|
||||
}
|
||||
|
||||
/** Crossfades the grid. Zero hides it outright rather than drawing a fully transparent pass. */
|
||||
setStrength(strength: number): void {
|
||||
const clamped = Math.max(0, Math.min(1, strength));
|
||||
this.material.opacity = clamped * this.baseOpacity;
|
||||
this.object.visible = clamped > 0;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.object.removeFromParent();
|
||||
this.geometry.dispose();
|
||||
this.material.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The vertical lines dropped from objects onto the reference plane — the other half of what
|
||||
* makes the grid work. A point floating over a grid still has ambiguous height; a point with a
|
||||
* line down to a marked spot on the grid does not.
|
||||
*
|
||||
* Drawn as one `LineSegments` with a fixed-capacity buffer and a draw range, so following a
|
||||
* changing set of stars costs a buffer write rather than a rebuild.
|
||||
*/
|
||||
export class TetherField {
|
||||
readonly object: THREE.LineSegments;
|
||||
|
||||
private readonly geometry = new THREE.BufferGeometry();
|
||||
private readonly material: THREE.LineBasicMaterial;
|
||||
private readonly positions: Float32Array;
|
||||
private readonly maxCount: number;
|
||||
private readonly normal: THREE.Vector3;
|
||||
private readonly peakOpacity: number;
|
||||
|
||||
constructor(maxCount: number, options: { color?: THREE.ColorRepresentation; normal?: THREE.Vector3; opacity?: number } = {}) {
|
||||
this.maxCount = maxCount;
|
||||
this.normal = (options.normal ?? galacticNormal()).clone().normalize();
|
||||
this.peakOpacity = options.opacity ?? 0.45;
|
||||
this.positions = new Float32Array(maxCount * 6);
|
||||
const color = options.color ?? 0x4dd7ff;
|
||||
this.geometry.setAttribute('position', new THREE.BufferAttribute(this.positions, 3));
|
||||
this.geometry.setDrawRange(0, 0);
|
||||
|
||||
this.material = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending });
|
||||
this.object = new THREE.LineSegments(this.geometry, this.material);
|
||||
// The buffer is rewritten in place as the visible set changes, so its bounds are stale by
|
||||
// construction; culling on those bounds would blink the whole field in and out.
|
||||
this.object.frustumCulled = false;
|
||||
this.object.visible = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a tether from each point onto the plane through the origin with this field's normal,
|
||||
* offset along that normal by `planeOffset`.
|
||||
*
|
||||
* The offset is `0` for a plane through the origin — the Sun in the galaxy view, the host star
|
||||
* in the system view — and `-SUN_HEIGHT_ABOVE_MIDPLANE_PC` for a grid on the Galaxy's true
|
||||
* midplane. Points past the field's capacity are dropped.
|
||||
*/
|
||||
setTargets(points: readonly THREE.Vector3[], planeOffset = 0): void {
|
||||
const normal = this.normal;
|
||||
const count = Math.min(points.length, this.maxCount);
|
||||
|
||||
for (let index = 0; index < count; index++) {
|
||||
const point = points[index];
|
||||
const height = point.dot(normal) - planeOffset;
|
||||
this.positions.set(
|
||||
[point.x, point.y, point.z, point.x - normal.x * height, point.y - normal.y * height, point.z - normal.z * height],
|
||||
index * 6
|
||||
);
|
||||
}
|
||||
|
||||
this.geometry.setDrawRange(0, count * 2);
|
||||
this.geometry.getAttribute('position').needsUpdate = true;
|
||||
}
|
||||
|
||||
/** Crossfades the tethers, matching whichever grid they are dropping onto. */
|
||||
setStrength(strength: number): void {
|
||||
const clamped = Math.max(0, Math.min(1, strength));
|
||||
this.material.opacity = clamped * this.peakOpacity;
|
||||
this.object.visible = clamped > 0;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.object.removeFromParent();
|
||||
this.geometry.dispose();
|
||||
this.material.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DISC_RADIUS_PC, equatorialToGalactic, SUN_GALACTOCENTRIC_RADIUS_PC, SUN_HEIGHT_ABOVE_MIDPLANE_PC } from '../../shared/astro/galaxy';
|
||||
import { createRandom, DEFAULT_PARTICLE_COUNTS, generateMilkyWayParticles } from './milky-way-model';
|
||||
|
||||
/** A small, fast budget — the shape of the model does not depend on how many particles trace it. */
|
||||
const TEST_COUNTS = { arms: 3000, disc: 1500, bulge: 1200, halo: 200 };
|
||||
const TEST_BUDGET = TEST_COUNTS.arms + TEST_COUNTS.disc + TEST_COUNTS.bulge + TEST_COUNTS.halo;
|
||||
|
||||
/** Galactocentric radius and height above the midplane of the i-th particle, in parsecs. */
|
||||
function galactocentric(positions: Float32Array, index: number): { radiusPc: number; heightPc: number } {
|
||||
const galactic = equatorialToGalactic({ x: positions[index * 3], y: positions[index * 3 + 1], z: positions[index * 3 + 2] });
|
||||
return {
|
||||
radiusPc: Math.hypot(SUN_GALACTOCENTRIC_RADIUS_PC - galactic.x, galactic.y),
|
||||
heightPc: galactic.z + SUN_HEIGHT_ABOVE_MIDPLANE_PC
|
||||
};
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.floor(sorted.length / 2)];
|
||||
}
|
||||
|
||||
describe('createRandom', () => {
|
||||
it('is deterministic for a given seed', () => {
|
||||
const a = createRandom(7);
|
||||
const b = createRandom(7);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(a()).toBe(b());
|
||||
}
|
||||
});
|
||||
|
||||
it('stays inside the unit interval', () => {
|
||||
const random = createRandom(99);
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
const value = random();
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
expect(value).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('produces different streams for different seeds', () => {
|
||||
expect(createRandom(1)()).not.toBe(createRandom(2)());
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMilkyWayParticles', () => {
|
||||
const particles = generateMilkyWayParticles(1234, TEST_COUNTS);
|
||||
|
||||
it('is the same Galaxy on every run, so the map does not reshuffle on reload', () => {
|
||||
const again = generateMilkyWayParticles(1234, TEST_COUNTS);
|
||||
expect(again.count).toBe(particles.count);
|
||||
expect(Array.from(again.positions.slice(0, 300))).toEqual(Array.from(particles.positions.slice(0, 300)));
|
||||
});
|
||||
|
||||
it('places most of the requested budget, rejecting only the samples that miss', () => {
|
||||
expect(particles.count).toBeLessThanOrEqual(TEST_BUDGET);
|
||||
expect(particles.count).toBeGreaterThan(TEST_BUDGET * 0.75);
|
||||
});
|
||||
|
||||
it('emits finite positions, sizes and alphas throughout', () => {
|
||||
for (let index = 0; index < particles.count; index++) {
|
||||
expect(Number.isFinite(particles.positions[index * 3])).toBe(true);
|
||||
expect(Number.isFinite(particles.positions[index * 3 + 1])).toBe(true);
|
||||
expect(Number.isFinite(particles.positions[index * 3 + 2])).toBe(true);
|
||||
expect(particles.sizes[index]).toBeGreaterThan(0);
|
||||
expect(particles.alphas[index]).toBeGreaterThan(0);
|
||||
expect(particles.alphas[index]).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every colour channel inside the displayable range', () => {
|
||||
for (let index = 0; index < particles.count * 3; index++) {
|
||||
expect(particles.colors[index]).toBeGreaterThanOrEqual(0);
|
||||
expect(particles.colors[index]).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every particle inside the modelled galaxy, halo included', () => {
|
||||
for (let index = 0; index < particles.count; index++) {
|
||||
expect(galactocentric(particles.positions, index).radiusPc).toBeLessThan(DISC_RADIUS_PC * 1.3);
|
||||
}
|
||||
});
|
||||
|
||||
it('builds a disc rather than a ball: half the particles sit within 300 pc of the midplane', () => {
|
||||
const heights: number[] = [];
|
||||
for (let index = 0; index < particles.count; index++) {
|
||||
heights.push(Math.abs(galactocentric(particles.positions, index).heightPc));
|
||||
}
|
||||
expect(median(heights)).toBeLessThan(300);
|
||||
});
|
||||
|
||||
it('leaves the centre denser than the outskirts', () => {
|
||||
let inner = 0;
|
||||
let outer = 0;
|
||||
for (let index = 0; index < particles.count; index++) {
|
||||
const { radiusPc } = galactocentric(particles.positions, index);
|
||||
if (radiusPc < 4000) {
|
||||
inner++;
|
||||
} else if (radiusPc > 12000) {
|
||||
outer++;
|
||||
}
|
||||
}
|
||||
expect(inner).toBeGreaterThan(outer);
|
||||
});
|
||||
|
||||
it('puts the Sun in the disc, not off its edge', () => {
|
||||
// The whole point of the placement: the local star field has to sit inside the model, about
|
||||
// half way out, rather than floating beside it.
|
||||
let neighbours = 0;
|
||||
for (let index = 0; index < particles.count; index++) {
|
||||
const x = particles.positions[index * 3];
|
||||
const y = particles.positions[index * 3 + 1];
|
||||
const z = particles.positions[index * 3 + 2];
|
||||
if (Math.hypot(x, y, z) < 2000) {
|
||||
neighbours++;
|
||||
}
|
||||
}
|
||||
expect(neighbours).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('spans a full turn in azimuth, so the arms wrap rather than forming a fan', () => {
|
||||
const quadrants = new Set<number>();
|
||||
for (let index = 0; index < particles.count; index++) {
|
||||
const galactic = equatorialToGalactic({
|
||||
x: particles.positions[index * 3],
|
||||
y: particles.positions[index * 3 + 1],
|
||||
z: particles.positions[index * 3 + 2]
|
||||
});
|
||||
const angle = Math.atan2(galactic.y, SUN_GALACTOCENTRIC_RADIUS_PC - galactic.x);
|
||||
quadrants.add(Math.floor(((angle + Math.PI) / (Math.PI / 2)) % 4));
|
||||
}
|
||||
expect(quadrants.size).toBe(4);
|
||||
});
|
||||
|
||||
it('defaults to a budget big enough to read as a galaxy', () => {
|
||||
expect(DEFAULT_PARTICLE_COUNTS.arms).toBeGreaterThan(DEFAULT_PARTICLE_COUNTS.disc);
|
||||
expect(DEFAULT_PARTICLE_COUNTS.halo).toBeLessThan(DEFAULT_PARTICLE_COUNTS.bulge);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
import {
|
||||
armRadiusPc,
|
||||
BAR_HALF_LENGTH_PC,
|
||||
BAR_HALF_THICKNESS_PC,
|
||||
BAR_HALF_WIDTH_PC,
|
||||
BAR_POSITION_ANGLE_DEG,
|
||||
DISC_RADIUS_PC,
|
||||
DISC_SCALE_HEIGHT_PC,
|
||||
DISC_SCALE_LENGTH_PC,
|
||||
galacticToEquatorial,
|
||||
MILKY_WAY_ARMS,
|
||||
ORION_SPUR,
|
||||
SpiralArm,
|
||||
SUN_GALACTOCENTRIC_RADIUS_PC,
|
||||
SUN_HEIGHT_ABOVE_MIDPLANE_PC
|
||||
} from '../../shared/astro/galaxy';
|
||||
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
|
||||
/**
|
||||
* Particle budget per population. Between them these are ~46k instanced quads, which is the
|
||||
* same order as the star field and draws in a single call.
|
||||
*/
|
||||
export interface GalaxyParticleCounts {
|
||||
readonly arms: number;
|
||||
readonly disc: number;
|
||||
readonly bulge: number;
|
||||
readonly halo: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PARTICLE_COUNTS: GalaxyParticleCounts = {
|
||||
arms: 24000,
|
||||
disc: 12000,
|
||||
bulge: 9000,
|
||||
halo: 1600
|
||||
};
|
||||
|
||||
/** Vertical scale height of the star-forming ridge in an arm — much thinner than the disc. */
|
||||
const ARM_SCALE_HEIGHT_PC = 130;
|
||||
/** Fraction of arm particles drawn as bright star-forming knots rather than ordinary field. */
|
||||
const HII_REGION_FRACTION = 0.05;
|
||||
|
||||
/** Particle diameters in parsecs. These are cloud-sized on purpose: the model is haze, not stars. */
|
||||
const ARM_SIZE_PC = { min: 70, max: 240 } as const;
|
||||
const DISC_SIZE_PC = { min: 120, max: 420 } as const;
|
||||
const BULGE_SIZE_PC = { min: 90, max: 300 } as const;
|
||||
const HALO_SIZE_PC = { min: 110, max: 260 } as const;
|
||||
const HII_SIZE_MULTIPLIER = 1.9;
|
||||
|
||||
/**
|
||||
* The palette. Young blue-white stars trace the arms, star formation lights them pink, the
|
||||
* bar and bulge are old and red, and the smooth disc between the arms is a dim yellow-white.
|
||||
*/
|
||||
const ARM_INNER_COLOR = [0.62, 0.74, 1.0] as const;
|
||||
const ARM_OUTER_COLOR = [0.78, 0.86, 1.0] as const;
|
||||
const HII_COLOR = [1.0, 0.48, 0.66] as const;
|
||||
const BULGE_CORE_COLOR = [1.0, 0.87, 0.64] as const;
|
||||
const BULGE_EDGE_COLOR = [1.0, 0.68, 0.38] as const;
|
||||
const DISC_COLOR = [0.72, 0.74, 0.82] as const;
|
||||
const HALO_COLOR = [0.55, 0.6, 0.78] as const;
|
||||
|
||||
export interface GalaxyParticles {
|
||||
readonly count: number;
|
||||
/** Equatorial-frame positions in parsecs from the Sun, packed xyz. */
|
||||
readonly positions: Float32Array;
|
||||
readonly colors: Float32Array;
|
||||
/** World-space diameter, in parsecs. */
|
||||
readonly sizes: Float32Array;
|
||||
readonly alphas: Float32Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small, fast, seedable PRNG (mulberry32). `Math.random` would do visually, but the model would
|
||||
* then be different on every reload and untestable — this way the Galaxy is the same Galaxy
|
||||
* every time, and a test can assert on where its particles land.
|
||||
*/
|
||||
export function createRandom(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** Standard normal sample, by the polar form of Box-Muller. */
|
||||
function gaussian(random: () => number): number {
|
||||
let u = 0;
|
||||
let v = 0;
|
||||
let s = 0;
|
||||
do {
|
||||
u = random() * 2 - 1;
|
||||
v = random() * 2 - 1;
|
||||
s = u * u + v * v;
|
||||
} while (s === 0 || s >= 1);
|
||||
return u * Math.sqrt((-2 * Math.log(s)) / s);
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function lerpColor(a: readonly number[], b: readonly number[], t: number): [number, number, number] {
|
||||
return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a galactocentric offset in the plane (heliocentric-parallel axes: +X toward the centre,
|
||||
* +Y toward longitude 90) plus a height above the midplane into a heliocentric galactic
|
||||
* position. The polar form of the same mapping lives in `galaxy.ts`; the bar is easier to write
|
||||
* in Cartesian, so it gets this one.
|
||||
*/
|
||||
function fromCentreOffset(dx: number, dy: number, heightPc: number): { x: number; y: number; z: number } {
|
||||
return { x: SUN_GALACTOCENTRIC_RADIUS_PC + dx, y: dy, z: heightPc - SUN_HEIGHT_ABOVE_MIDPLANE_PC };
|
||||
}
|
||||
|
||||
interface Writer {
|
||||
push(galactic: { x: number; y: number; z: number }, color: readonly number[], sizePc: number, alpha: number): void;
|
||||
}
|
||||
|
||||
function createWriter(capacity: number): Writer & GalaxyParticles & { finish(): GalaxyParticles } {
|
||||
const positions = new Float32Array(capacity * 3);
|
||||
const colors = new Float32Array(capacity * 3);
|
||||
const sizes = new Float32Array(capacity);
|
||||
const alphas = new Float32Array(capacity);
|
||||
let count = 0;
|
||||
|
||||
return {
|
||||
positions,
|
||||
colors,
|
||||
sizes,
|
||||
alphas,
|
||||
get count() {
|
||||
return count;
|
||||
},
|
||||
push(galactic, color, sizePc, alpha) {
|
||||
// Everything is modelled in galactic coordinates and rotated once, here, into the frame
|
||||
// the star field and orbits already share.
|
||||
const equatorial = galacticToEquatorial(galactic);
|
||||
positions[count * 3] = equatorial.x;
|
||||
positions[count * 3 + 1] = equatorial.y;
|
||||
positions[count * 3 + 2] = equatorial.z;
|
||||
colors[count * 3] = color[0];
|
||||
colors[count * 3 + 1] = color[1];
|
||||
colors[count * 3 + 2] = color[2];
|
||||
sizes[count] = sizePc;
|
||||
alphas[count] = alpha;
|
||||
count++;
|
||||
},
|
||||
finish() {
|
||||
return { count, positions, colors, sizes, alphas };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Picks an arm, weighted, so the two grand-design arms dominate the minor ones. */
|
||||
function pickArm(random: () => number, arms: readonly SpiralArm[]): SpiralArm {
|
||||
const total = arms.reduce((sum, arm) => sum + arm.weight, 0);
|
||||
let roll = random() * total;
|
||||
for (const arm of arms) {
|
||||
roll -= arm.weight;
|
||||
if (roll <= 0) {
|
||||
return arm;
|
||||
}
|
||||
}
|
||||
return arms[arms.length - 1];
|
||||
}
|
||||
|
||||
function addArmParticles(writer: Writer, random: () => number, count: number): void {
|
||||
const arms = [...MILKY_WAY_ARMS, ORION_SPUR];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const arm = pickArm(random, arms);
|
||||
// Biased toward the start of the sweep, which is the inner, denser end of every arm.
|
||||
const t = Math.pow(random(), 1.4);
|
||||
const beta = lerp(arm.fromAzimuthDeg, arm.toAzimuthDeg, t);
|
||||
const spineRadius = armRadiusPc(arm, beta);
|
||||
if (spineRadius > DISC_RADIUS_PC || spineRadius < BAR_HALF_LENGTH_PC * 0.5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const offset = gaussian(random) * arm.widthPc;
|
||||
const radius = spineRadius + offset;
|
||||
if (radius <= 0) {
|
||||
continue;
|
||||
}
|
||||
const height = gaussian(random) * ARM_SCALE_HEIGHT_PC;
|
||||
const angle = beta * DEG_TO_RAD;
|
||||
const galactic = fromCentreOffset(-radius * Math.cos(angle), radius * Math.sin(angle), height);
|
||||
|
||||
const radialT = Math.min(radius / DISC_RADIUS_PC, 1);
|
||||
const isHii = random() < HII_REGION_FRACTION;
|
||||
const color = isHii ? HII_COLOR : lerpColor(ARM_INNER_COLOR, ARM_OUTER_COLOR, radialT);
|
||||
const size = lerp(ARM_SIZE_PC.min, ARM_SIZE_PC.max, random()) * (isHii ? HII_SIZE_MULTIPLIER : 1);
|
||||
// Fades with radius (the arms thin out) and with distance off the spine (they have edges).
|
||||
const ridgeFalloff = Math.exp(-(offset * offset) / (2 * arm.widthPc * arm.widthPc));
|
||||
const alpha = (isHii ? 0.85 : 0.4) * ridgeFalloff * lerp(1, 0.35, radialT);
|
||||
|
||||
writer.push(galactic, color, size, alpha);
|
||||
}
|
||||
}
|
||||
|
||||
function addDiscParticles(writer: Writer, random: () => number, count: number): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Inverse-transform sample of an exponential disc, rejected past the visible edge.
|
||||
const radius = -DISC_SCALE_LENGTH_PC * Math.log(1 - random());
|
||||
// The inner cut is where the bulge takes over, not a hole: set it at the bar's short axis
|
||||
// rather than its long one, or the model has a visible gap either side of the bar.
|
||||
if (radius > DISC_RADIUS_PC || radius < BAR_HALF_WIDTH_PC) {
|
||||
continue;
|
||||
}
|
||||
const angle = random() * Math.PI * 2;
|
||||
const height = gaussian(random) * DISC_SCALE_HEIGHT_PC;
|
||||
const galactic = fromCentreOffset(-radius * Math.cos(angle), radius * Math.sin(angle), height);
|
||||
|
||||
const radialT = Math.min(radius / DISC_RADIUS_PC, 1);
|
||||
writer.push(galactic, DISC_COLOR, lerp(DISC_SIZE_PC.min, DISC_SIZE_PC.max, random()), lerp(0.16, 0.03, radialT));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Share of the bulge budget spent on the bar rather than on the rounder spheroid it sits inside.
|
||||
* Both are needed: the bar alone leaves a void either side of its short axis, between it and the
|
||||
* radius the arms and disc start at.
|
||||
*/
|
||||
const BAR_SHARE_OF_BULGE = 0.6;
|
||||
/** Gaussian width of the inner spheroid, and how much it is flattened toward the disc. */
|
||||
const SPHEROID_SIGMA_PC = 1150;
|
||||
const SPHEROID_FLATTENING = 0.62;
|
||||
|
||||
function addBulgeParticles(writer: Writer, random: () => number, count: number): void {
|
||||
const phi = BAR_POSITION_ANGLE_DEG * DEG_TO_RAD;
|
||||
const alongX = -Math.cos(phi);
|
||||
const alongY = Math.sin(phi);
|
||||
const acrossX = Math.sin(phi);
|
||||
const acrossY = Math.cos(phi);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
let dx: number;
|
||||
let dy: number;
|
||||
let height: number;
|
||||
let distance: number;
|
||||
|
||||
if (random() < BAR_SHARE_OF_BULGE) {
|
||||
// A triaxial Gaussian: long down the bar, narrow across it, flattened vertically.
|
||||
const along = gaussian(random) * (BAR_HALF_LENGTH_PC / 2);
|
||||
const across = gaussian(random) * (BAR_HALF_WIDTH_PC / 2);
|
||||
height = gaussian(random) * (BAR_HALF_THICKNESS_PC / 2);
|
||||
dx = along * alongX + across * acrossX;
|
||||
dy = along * alongY + across * acrossY;
|
||||
distance = Math.hypot(along, across, height);
|
||||
} else {
|
||||
dx = gaussian(random) * SPHEROID_SIGMA_PC;
|
||||
dy = gaussian(random) * SPHEROID_SIGMA_PC;
|
||||
height = gaussian(random) * SPHEROID_SIGMA_PC * SPHEROID_FLATTENING;
|
||||
distance = Math.hypot(dx, dy, height);
|
||||
}
|
||||
|
||||
const coreT = Math.min(distance / BAR_HALF_LENGTH_PC, 1);
|
||||
writer.push(
|
||||
fromCentreOffset(dx, dy, height),
|
||||
lerpColor(BULGE_CORE_COLOR, BULGE_EDGE_COLOR, coreT),
|
||||
lerp(BULGE_SIZE_PC.min, BULGE_SIZE_PC.max, random()),
|
||||
lerp(0.3, 0.06, coreT)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function addHaloParticles(writer: Writer, random: () => number, count: number): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
// A thin spherical scatter (globular clusters and halo field) so the disc is not a bare
|
||||
// plate floating in the void.
|
||||
const radius = DISC_RADIUS_PC * (0.35 + 0.85 * Math.pow(random(), 0.7));
|
||||
const cosTheta = random() * 2 - 1;
|
||||
const sinTheta = Math.sqrt(1 - cosTheta * cosTheta);
|
||||
const angle = random() * Math.PI * 2;
|
||||
const galactic = fromCentreOffset(radius * sinTheta * Math.cos(angle), radius * sinTheta * Math.sin(angle), radius * cosTheta);
|
||||
|
||||
writer.push(galactic, HALO_COLOR, lerp(HALO_SIZE_PC.min, HALO_SIZE_PC.max, random()), 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scatters the Milky Way's particle cloud around the structural model in `galaxy.ts` and returns
|
||||
* it packed for instanced rendering, already rotated into the scene's equatorial frame.
|
||||
*
|
||||
* Some samples are rejected (an arm particle that lands inside the bar, a disc particle past the
|
||||
* visible edge), so the returned `count` is a little below the requested budget — the arrays are
|
||||
* allocated at capacity and the count says how much of them is live.
|
||||
*/
|
||||
export function generateMilkyWayParticles(seed = 20260804, counts: GalaxyParticleCounts = DEFAULT_PARTICLE_COUNTS): GalaxyParticles {
|
||||
const random = createRandom(seed);
|
||||
const writer = createWriter(counts.arms + counts.disc + counts.bulge + counts.halo);
|
||||
|
||||
addBulgeParticles(writer, random, counts.bulge);
|
||||
addArmParticles(writer, random, counts.arms);
|
||||
addDiscParticles(writer, random, counts.disc);
|
||||
addHaloParticles(writer, random, counts.halo);
|
||||
|
||||
return writer.finish();
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GALACTIC_LANDMARKS } from '../../shared/astro/galaxy';
|
||||
import { GALAXY_FADE_FAR_PC, GALAXY_FADE_NEAR_PC, MilkyWayRenderer } from './milky-way-renderer';
|
||||
|
||||
const SMALL_COUNTS = { arms: 400, disc: 200, bulge: 200, halo: 50 };
|
||||
|
||||
describe('MilkyWayRenderer', () => {
|
||||
it('draws one instance per particle the model placed', () => {
|
||||
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||
expect(renderer.particleCount).toBeGreaterThan(0);
|
||||
expect((renderer.object.geometry as THREE.InstancedBufferGeometry).instanceCount).toBe(renderer.particleCount);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('stays hidden until the camera has pulled back, so the local view pays nothing for it', () => {
|
||||
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||
expect(renderer.object.visible).toBe(false);
|
||||
|
||||
expect(renderer.setViewerDistancePc(50)).toBe(0);
|
||||
expect(renderer.object.visible).toBe(false);
|
||||
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('fades in across the crossfade band and holds at full strength beyond it', () => {
|
||||
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||
|
||||
expect(renderer.setViewerDistancePc(GALAXY_FADE_NEAR_PC)).toBe(0);
|
||||
expect(renderer.setViewerDistancePc((GALAXY_FADE_NEAR_PC + GALAXY_FADE_FAR_PC) / 2)).toBeCloseTo(0.5, 6);
|
||||
expect(renderer.setViewerDistancePc(GALAXY_FADE_FAR_PC)).toBe(1);
|
||||
expect(renderer.setViewerDistancePc(80000)).toBe(1);
|
||||
expect(renderer.object.visible).toBe(true);
|
||||
expect(renderer.strength).toBe(1);
|
||||
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('never culls itself, since its instances are nowhere near the geometry it was built from', () => {
|
||||
// The billboard quad sits at the origin and the particles are placed in the vertex shader,
|
||||
// so the mesh's own bounds say nothing about where it is drawn.
|
||||
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||
expect(renderer.object.frustumCulled).toBe(false);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('offers a label anchor for every structural landmark, namespaced away from the star ids', () => {
|
||||
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||
const labels = renderer.labelPoints();
|
||||
|
||||
expect(labels).toHaveLength(GALACTIC_LANDMARKS.length);
|
||||
expect(labels.map((label) => label.name)).toContain('Sagittarius A*');
|
||||
for (const label of labels) {
|
||||
expect(String(label.id).startsWith('galactic:')).toBe(true);
|
||||
expect(Number.isFinite(label.x + label.y + label.z)).toBe(true);
|
||||
}
|
||||
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('detaches itself on dispose, so a torn-down scene does not keep it alive', () => {
|
||||
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||
new THREE.Group().add(renderer.object);
|
||||
|
||||
renderer.dispose();
|
||||
expect(renderer.object.parent).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { instancedBufferAttribute, smoothstep, uniform, uv, vec2 } from 'three/tsl';
|
||||
|
||||
import { GALACTIC_LANDMARKS, landmarkPositionPc } from '../../shared/astro/galaxy';
|
||||
import { LabeledPoint } from './star-label-overlay';
|
||||
import { generateMilkyWayParticles, GalaxyParticleCounts } from './milky-way-model';
|
||||
|
||||
/**
|
||||
* Camera distances (parsecs from the Sun) between which the Galaxy model fades in. Below the
|
||||
* near figure the view is the real, measured star field and the model is entirely hidden; above
|
||||
* the far figure the model is at full strength and the 50 pc catalogue bubble is a single point.
|
||||
*/
|
||||
export const GALAXY_FADE_NEAR_PC = 400;
|
||||
export const GALAXY_FADE_FAR_PC = 2500;
|
||||
|
||||
/** A unit quad centred on the origin — the billboard every particle is drawn on. */
|
||||
function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometry {
|
||||
const geometry = new THREE.InstancedBufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]), 3));
|
||||
geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), 2));
|
||||
geometry.setIndex([0, 1, 2, 0, 2, 3]);
|
||||
geometry.instanceCount = instanceCount;
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the Milky Way itself: the bar and bulge, five spiral arms, the smooth disc between them
|
||||
* and a thin halo, as one instanced cloud of soft camera-facing billboards.
|
||||
*
|
||||
* The particles are **illustrative**. Their skeleton is not — arm radii, pitch angles, the
|
||||
* Sun's galactocentric distance and the tilt of the disc against the sky are all measured
|
||||
* quantities, and the model is built from them in `galaxy.ts`. What no catalogue can supply is
|
||||
* the position of each star in the disc, because dust hides most of it from us, so the cloud
|
||||
* around that skeleton is scattered rather than observed. The UI says so on the galactic level.
|
||||
*
|
||||
* Sizes are world-space here, unlike the star field's angular ones: these particles stand for
|
||||
* clouds hundreds of parsecs across, so they should grow as the camera closes on them.
|
||||
*/
|
||||
export class MilkyWayRenderer {
|
||||
readonly object: THREE.Mesh;
|
||||
/** How many instances the model actually placed, after rejected samples. */
|
||||
readonly particleCount: number;
|
||||
|
||||
private readonly geometry: THREE.InstancedBufferGeometry;
|
||||
private readonly material: THREE.SpriteNodeMaterial;
|
||||
private readonly fade = uniform(0);
|
||||
private fadeValue = 0;
|
||||
|
||||
constructor(seed?: number, counts?: GalaxyParticleCounts) {
|
||||
const particles = generateMilkyWayParticles(seed, counts);
|
||||
this.particleCount = particles.count;
|
||||
this.geometry = createQuadGeometry(particles.count);
|
||||
|
||||
const positionAttribute = new THREE.InstancedBufferAttribute(particles.positions, 3);
|
||||
const colorAttribute = new THREE.InstancedBufferAttribute(particles.colors, 3);
|
||||
const sizeAttribute = new THREE.InstancedBufferAttribute(particles.sizes, 1);
|
||||
const alphaAttribute = new THREE.InstancedBufferAttribute(particles.alphas, 1);
|
||||
|
||||
this.material = new THREE.SpriteNodeMaterial({
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
this.material.positionNode = instancedBufferAttribute(positionAttribute, 'vec3');
|
||||
this.material.scaleNode = instancedBufferAttribute(sizeAttribute, 'float');
|
||||
this.material.colorNode = instancedBufferAttribute(colorAttribute, 'vec3');
|
||||
// A gentler falloff than the star field's: these are clouds, and the tight curve that makes
|
||||
// a star read as a bright point makes a cloud read as a solid ball.
|
||||
const radius = uv().sub(vec2(0.5)).length();
|
||||
const falloff = smoothstep(0.0, 0.5, radius).oneMinus().pow(1.6);
|
||||
this.material.opacityNode = falloff.mul(instancedBufferAttribute(alphaAttribute, 'float')).mul(this.fade);
|
||||
|
||||
this.object = new THREE.Mesh(this.geometry, this.material);
|
||||
// The quad's own bounds sit at the origin and say nothing about where the instances are.
|
||||
this.object.frustumCulled = false;
|
||||
this.object.visible = false;
|
||||
// Behind everything else: the model is a backdrop for the real data, never in front of it.
|
||||
this.object.renderOrder = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crossfades the model against how far the camera has pulled back, and returns the resulting
|
||||
* strength (0-1). The mesh is skipped outright at zero so the local view pays nothing for it.
|
||||
*/
|
||||
setViewerDistancePc(distancePc: number): number {
|
||||
const t = (distancePc - GALAXY_FADE_NEAR_PC) / (GALAXY_FADE_FAR_PC - GALAXY_FADE_NEAR_PC);
|
||||
this.fadeValue = Math.max(0, Math.min(1, t));
|
||||
this.fade.value = this.fadeValue;
|
||||
this.object.visible = this.fadeValue > 0;
|
||||
return this.fadeValue;
|
||||
}
|
||||
|
||||
get strength(): number {
|
||||
return this.fadeValue;
|
||||
}
|
||||
|
||||
/** Named structural landmarks — the centre, the Sun, and one label per arm. */
|
||||
labelPoints(): readonly LabeledPoint[] {
|
||||
return GALACTIC_LANDMARKS.map((landmark) => {
|
||||
const position = landmarkPositionPc(landmark);
|
||||
return { id: `galactic:${landmark.id}`, name: landmark.name, kind: landmark.kind, x: position.x, y: position.y, z: position.z };
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.object.removeFromParent();
|
||||
this.geometry.dispose();
|
||||
this.material.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { colorIndexToRgb, magnitudeToPointSize, selectDrawnStars, StarFieldRenderer } from './star-field-renderer';
|
||||
|
||||
function star(overrides: Partial<StarRecord> = {}): StarRecord {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'Test Star',
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
magnitude: 5,
|
||||
spectralType: 'G2V',
|
||||
colorIndex: 0.65,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function packPositions(stars: readonly StarRecord[]): Float32Array {
|
||||
return new Float32Array(stars.flatMap((s) => [s.x, s.y, s.z]));
|
||||
}
|
||||
|
||||
/** A camera looking down -Z from the origin, framing everything in front of it. */
|
||||
function testCamera(): THREE.PerspectiveCamera {
|
||||
const camera = new THREE.PerspectiveCamera(55, 16 / 9, 0.01, 5000);
|
||||
camera.position.set(0, 0, 0);
|
||||
camera.lookAt(0, 0, -1);
|
||||
camera.updateMatrixWorld(true);
|
||||
camera.updateProjectionMatrix();
|
||||
return camera;
|
||||
}
|
||||
|
||||
describe('colorIndexToRgb', () => {
|
||||
it('tints a hot, low-index star blue-white', () => {
|
||||
const color = colorIndexToRgb(-0.3);
|
||||
expect(color.b).toBeGreaterThan(color.r);
|
||||
});
|
||||
|
||||
it('tints a cool, high-index star orange-red', () => {
|
||||
const color = colorIndexToRgb(1.8);
|
||||
expect(color.r).toBeGreaterThan(color.b);
|
||||
});
|
||||
|
||||
it('moves monotonically from blue toward red as the index rises', () => {
|
||||
const blueness = [-0.3, 0.2, 0.65, 1.2, 1.9].map((index) => {
|
||||
const color = colorIndexToRgb(index);
|
||||
return color.b - color.r;
|
||||
});
|
||||
expect([...blueness].sort((a, b) => b - a)).toEqual(blueness);
|
||||
});
|
||||
|
||||
describe('when the catalog has no photometry', () => {
|
||||
// ~10% of nearby HYG stars have a blank colour-index cell. Reading that as 0 (which is a
|
||||
// real index, meaning a hot A-type star) painted several hundred red dwarfs blue-white.
|
||||
it('falls back to the spectral type rather than to zero', () => {
|
||||
const fromNull = colorIndexToRgb(null, 'M4');
|
||||
const asIfZero = colorIndexToRgb(0);
|
||||
|
||||
expect(fromNull.r).toBeGreaterThan(fromNull.b);
|
||||
expect(asIfZero.b).toBeGreaterThan(asIfZero.r);
|
||||
});
|
||||
|
||||
it('matches the colour the same spectral type would give explicitly', () => {
|
||||
// K5 sits halfway between the K anchor (0.81) and the M anchor (1.40).
|
||||
const derived = colorIndexToRgb(null, 'K5');
|
||||
const explicit = colorIndexToRgb(1.105);
|
||||
|
||||
expect(derived.r).toBeCloseTo(explicit.r, 6);
|
||||
expect(derived.g).toBeCloseTo(explicit.g, 6);
|
||||
expect(derived.b).toBeCloseTo(explicit.b, 6);
|
||||
});
|
||||
|
||||
it('handles the bare lowercase classes HYG ships', () => {
|
||||
const color = colorIndexToRgb(null, 'm');
|
||||
expect(color.r).toBeGreaterThan(color.b);
|
||||
});
|
||||
|
||||
it('falls back to neutral when the star is unclassified too', () => {
|
||||
const color = colorIndexToRgb(null, 'Unknown');
|
||||
expect(color.r).toBeCloseTo(1, 6);
|
||||
expect(color.g).toBeCloseTo(1, 6);
|
||||
expect(color.b).toBeCloseTo(1, 6);
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers a measured index over the spectral type', () => {
|
||||
const measured = colorIndexToRgb(-0.3, 'M5');
|
||||
expect(measured.b).toBeGreaterThan(measured.r);
|
||||
});
|
||||
});
|
||||
|
||||
describe('magnitudeToPointSize', () => {
|
||||
it('renders brighter stars larger', () => {
|
||||
expect(magnitudeToPointSize(-1)).toBeGreaterThan(magnitudeToPointSize(12));
|
||||
});
|
||||
|
||||
it('clamps outside the magnitude range rather than running away', () => {
|
||||
expect(magnitudeToPointSize(-30)).toBe(magnitudeToPointSize(-2));
|
||||
expect(magnitudeToPointSize(50)).toBe(magnitudeToPointSize(10));
|
||||
});
|
||||
});
|
||||
|
||||
describe('StarFieldRenderer', () => {
|
||||
const stars = [star({ id: 10, name: 'A' }), star({ id: 20, name: 'B', colorIndex: null, spectralType: 'M4' })];
|
||||
|
||||
it('draws one instance per star from a single shared quad', () => {
|
||||
const renderer = new StarFieldRenderer(stars, packPositions(stars));
|
||||
const geometry = renderer.object.geometry as THREE.InstancedBufferGeometry;
|
||||
|
||||
expect(geometry.instanceCount).toBe(2);
|
||||
// Four corners of one quad, reused by every instance.
|
||||
expect(geometry.getAttribute('position').count).toBe(4);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('never culls itself, since its geometry sits at the origin', () => {
|
||||
// The quad's bounds say nothing about where the instances are, so culling would drop the
|
||||
// entire field whenever the origin left the frustum.
|
||||
const renderer = new StarFieldRenderer(stars, packPositions(stars));
|
||||
expect(renderer.object.frustumCulled).toBe(false);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('maps an instance index back to its HYG star id', () => {
|
||||
const renderer = new StarFieldRenderer(stars, packPositions(stars));
|
||||
|
||||
expect(renderer.starIdAt(0)).toBe(10);
|
||||
expect(renderer.starIdAt(1)).toBe(20);
|
||||
expect(renderer.starIdAt(99)).toBeUndefined();
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('handles an empty star field', () => {
|
||||
const renderer = new StarFieldRenderer([], new Float32Array(0));
|
||||
|
||||
expect((renderer.object.geometry as THREE.InstancedBufferGeometry).instanceCount).toBe(0);
|
||||
expect(renderer.starIdAt(0)).toBeUndefined();
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
describe('pickAt', () => {
|
||||
const camera = testCamera();
|
||||
// Two stars straight ahead, one well off to the side.
|
||||
const picked = [
|
||||
star({ id: 1, name: 'Near', x: 0, y: 0, z: -10, magnitude: 1 }),
|
||||
star({ id: 2, name: 'Far', x: 0, y: 0, z: -100, magnitude: 1 }),
|
||||
star({ id: 3, name: 'Aside', x: 40, y: 0, z: -10, magnitude: 1 })
|
||||
];
|
||||
|
||||
it('finds the star under the pointer', () => {
|
||||
const renderer = new StarFieldRenderer(picked, packPositions(picked));
|
||||
// Both Near and Far project to the screen centre; either is a correct hit.
|
||||
expect([1, 2]).toContain(renderer.pickAt(new THREE.Vector2(0, 0), camera));
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('returns undefined when the pointer is on empty sky', () => {
|
||||
const renderer = new StarFieldRenderer(picked, packPositions(picked));
|
||||
expect(renderer.pickAt(new THREE.Vector2(-0.9, 0.9), camera)).toBeUndefined();
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('ignores stars behind the camera', () => {
|
||||
// `project()` mirrors points behind the camera back onto the screen, so without an
|
||||
// explicit depth guard this star would be pickable at the centre of the view.
|
||||
const behind = [star({ id: 7, x: 0, y: 0, z: 10 })];
|
||||
const renderer = new StarFieldRenderer(behind, packPositions(behind));
|
||||
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined();
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('picks the star nearest the pointer when several are in view', () => {
|
||||
const spread = [
|
||||
star({ id: 1, x: 0, y: 0, z: -10 }),
|
||||
star({ id: 2, x: 0, y: 2, z: -10 }),
|
||||
star({ id: 3, x: 0, y: -2, z: -10 })
|
||||
];
|
||||
const renderer = new StarFieldRenderer(spread, packPositions(spread));
|
||||
|
||||
// Aim at where star 2 projects, and confirm we get it rather than its neighbours.
|
||||
const target = new THREE.Vector3(0, 2, -10).project(camera);
|
||||
expect(renderer.pickAt(new THREE.Vector2(target.x, target.y), camera)).toBe(2);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('gives a brighter star a larger hit area than a faint one', () => {
|
||||
const bright = [star({ id: 1, x: 0, y: 0, z: -10, magnitude: -1 })];
|
||||
const faint = [star({ id: 2, x: 0, y: 0, z: -10, magnitude: 14 })];
|
||||
const brightRenderer = new StarFieldRenderer(bright, packPositions(bright));
|
||||
const faintRenderer = new StarFieldRenderer(faint, packPositions(faint));
|
||||
|
||||
// Walk outward from the centre until each stops being pickable.
|
||||
const reach = (renderer: StarFieldRenderer): number => {
|
||||
let offset = 0;
|
||||
while (offset < 1 && renderer.pickAt(new THREE.Vector2(0, offset), camera) !== undefined) {
|
||||
offset += 0.001;
|
||||
}
|
||||
return offset;
|
||||
};
|
||||
|
||||
expect(reach(brightRenderer)).toBeGreaterThan(reach(faintRenderer));
|
||||
brightRenderer.dispose();
|
||||
faintRenderer.dispose();
|
||||
});
|
||||
|
||||
it('keeps even the faintest star clickable', () => {
|
||||
// A magnitude-15 star is drawn under 2 px across, so without the added slop the faint end
|
||||
// of the catalogue would demand sub-pixel accuracy.
|
||||
const faint = [star({ id: 5, x: 0, y: 0, z: -10, magnitude: 15 })];
|
||||
const renderer = new StarFieldRenderer(faint, packPositions(faint));
|
||||
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0.005), camera)).toBe(5);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('finds nothing in an empty field', () => {
|
||||
const renderer = new StarFieldRenderer([], new Float32Array(0));
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined();
|
||||
renderer.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** A star at a given distance along +X, with a given apparent magnitude. */
|
||||
function catalogueStar(id: number, distancePc: number, magnitude: number): StarRecord {
|
||||
return { id, name: `star-${id}`, x: distancePc, y: 0, z: 0, magnitude, spectralType: 'G2V', colorIndex: 0.6 };
|
||||
}
|
||||
|
||||
describe('selectDrawnStars', () => {
|
||||
it('draws everything when the catalogue fits the budget', () => {
|
||||
const catalogue = [catalogueStar(1, 10, 5), catalogueStar(2, 20, 6)];
|
||||
expect(Array.from(selectDrawnStars(catalogue, 10))).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('never draws more than the budget', () => {
|
||||
const catalogue = Array.from({ length: 500 }, (_, i) => catalogueStar(i, 200, i));
|
||||
expect(selectDrawnStars(catalogue, 50)).toHaveLength(50);
|
||||
});
|
||||
|
||||
it('keeps the whole solar neighbourhood, however faint', () => {
|
||||
// The load-bearing case: the nearest stars are overwhelmingly faint red dwarfs, and Proxima
|
||||
// Centauri is magnitude 11. A pure brightness cut would delete the part of the map that
|
||||
// matters most and holds the nearby planets.
|
||||
const proxima = catalogueStar(999, 1.3, 11.1);
|
||||
const catalogue = [proxima, ...Array.from({ length: 200 }, (_, i) => catalogueStar(i, 240, 2))];
|
||||
const drawn = selectDrawnStars(catalogue, 20);
|
||||
|
||||
expect(Array.from(drawn)).toContain(0);
|
||||
expect(drawn).toHaveLength(20);
|
||||
});
|
||||
|
||||
it('spends what is left on the brightest stars beyond the neighbourhood', () => {
|
||||
const catalogue = [catalogueStar(0, 10, 12), catalogueStar(1, 200, 8), catalogueStar(2, 200, 2), catalogueStar(3, 200, 5)];
|
||||
const drawn = Array.from(selectDrawnStars(catalogue, 3));
|
||||
|
||||
// The nearby faint one, then the two brightest distant ones — not the magnitude-8 straggler.
|
||||
expect(drawn).toEqual([0, 2, 3]);
|
||||
});
|
||||
|
||||
it('returns catalogue indices in order, so positions can be subset alongside', () => {
|
||||
const catalogue = Array.from({ length: 100 }, (_, i) => catalogueStar(i, 150, 100 - i));
|
||||
const drawn = Array.from(selectDrawnStars(catalogue, 10));
|
||||
expect(drawn).toEqual([...drawn].sort((a, b) => a - b));
|
||||
});
|
||||
});
|
||||
|
||||
describe('StarFieldRenderer render budget', () => {
|
||||
it('draws only the budget, and reports how many that was', () => {
|
||||
const catalogue = Array.from({ length: 300 }, (_, i) => catalogueStar(i, 200, i));
|
||||
const positions = new Float32Array(catalogue.flatMap((s) => [s.x, s.y, s.z]));
|
||||
const renderer = new StarFieldRenderer(catalogue, positions, 40);
|
||||
|
||||
expect(renderer.drawnCount).toBe(40);
|
||||
expect((renderer.object.geometry as THREE.InstancedBufferGeometry).instanceCount).toBe(40);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('keeps each drawn star with its own position after subsetting', () => {
|
||||
// The subtle failure this guards: repacking positions for a subset while the colours and
|
||||
// sizes follow a different order would give every star someone else's place in the sky.
|
||||
const catalogue = [catalogueStar(0, 5, 9), catalogueStar(1, 200, 1), catalogueStar(2, 200, 7)];
|
||||
const positions = new Float32Array(catalogue.flatMap((s) => [s.x, s.y, s.z]));
|
||||
const renderer = new StarFieldRenderer(catalogue, positions, 2);
|
||||
|
||||
expect(renderer.drawnCount).toBe(2);
|
||||
expect(renderer.starIdAt(0)).toBe(0);
|
||||
expect(renderer.starIdAt(1)).toBe(1);
|
||||
renderer.dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,70 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { attribute } from 'three/tsl';
|
||||
import { instancedBufferAttribute, smoothstep, uv, vec2 } from 'three/tsl';
|
||||
|
||||
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
|
||||
/** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */
|
||||
const MIN_POINT_SIZE = 1.5;
|
||||
const MAX_POINT_SIZE = 6;
|
||||
|
||||
/**
|
||||
* Star size is expressed in pixels for readability, but the material works in angular size, so
|
||||
* the two are related through the scene's vertical field of view and a reference viewport.
|
||||
* Because the size is angular, a star keeps the same share of the screen at any window size —
|
||||
* these pixel figures are exact only at this reference height.
|
||||
*/
|
||||
const REFERENCE_VIEWPORT_HEIGHT_PX = 900;
|
||||
const REFERENCE_FOV_DEGREES = 55;
|
||||
const PIXELS_TO_ANGULAR_SIZE =
|
||||
(2 * Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 180 / 2)) / REFERENCE_VIEWPORT_HEIGHT_PX;
|
||||
|
||||
/**
|
||||
* Extra click forgiveness added to a star's drawn radius, in NDC — roughly 4 px on the
|
||||
* reference viewport.
|
||||
*
|
||||
* Added rather than used as a floor. Stars are drawn 1.5-6 px across, so any floor generous
|
||||
* enough to make the faintest ones clickable would also exceed the brightest one's radius and
|
||||
* flatten every star to the same hit area. Adding keeps the ordering intact: a brighter star is
|
||||
* always the easier target, which is what the eye expects.
|
||||
*/
|
||||
const PICK_NDC_SLOP = 0.01;
|
||||
|
||||
/**
|
||||
* How many stars the field draws at once, however many the catalogue holds.
|
||||
*
|
||||
* The catalogue reaches as far as its parallaxes do — 68388 stars at 250 pc — but drawing all of
|
||||
* them is a cost paid every frame by every machine, and most of that cost buys 1.5-pixel dots.
|
||||
* So the *data* is the catalogue and the *drawing* is a budget, and the two are allowed to
|
||||
* differ. Everything still exists for search, for flying to, and for hosting planets.
|
||||
*
|
||||
* Currently set to the whole catalogue, which is what a GPU should be asked to do — this is one
|
||||
* instanced draw call, and a discrete card will not notice it. The budget still exists because
|
||||
* the catalogue is meant to grow past what any machine should draw at once: Gaia alone could
|
||||
* contribute a million stars, and at that point the selection below is what keeps the field
|
||||
* legible rather than a grey wash.
|
||||
*
|
||||
* Machines without a GPU do feel it. A software rasterizer measured here lost about a third of
|
||||
* its frame rate per 12000 stars drawn; if that matters for a deployment, this is the one number
|
||||
* to turn down.
|
||||
*/
|
||||
export const STAR_RENDER_BUDGET = 68388;
|
||||
|
||||
/**
|
||||
* Radius (parsecs) inside which every star is drawn regardless of brightness.
|
||||
*
|
||||
* A pure brightness cut would be defensible — apparent magnitude is exactly "how visible this
|
||||
* is" — but it would drop the solar neighbourhood, because the nearest stars are overwhelmingly
|
||||
* faint red dwarfs. Proxima Centauri is magnitude 11. Those are the stars this map is most about
|
||||
* and the ones that hold the nearby planets, so the neighbourhood is kept whole and the budget
|
||||
* is spent on the brightest of everything beyond it.
|
||||
*
|
||||
* Kept deliberately small against the catalogue's 250 pc reach. The guaranteed core occupies a
|
||||
* thousandth of that volume, so a generous radius spends most of the budget inside it and draws
|
||||
* a dense knot surrounded by nothing — which is a worse picture than the smaller catalogue was.
|
||||
*/
|
||||
export const ALWAYS_DRAWN_RADIUS_PC = 25;
|
||||
|
||||
const COLD_STAR_COLOR = new THREE.Color(0.65, 0.75, 1.0);
|
||||
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
|
||||
const WARM_STAR_COLOR = new THREE.Color(1.0, 0.6, 0.35);
|
||||
@@ -13,66 +72,203 @@ const WARM_STAR_COLOR = new THREE.Color(1.0, 0.6, 0.35);
|
||||
/**
|
||||
* Crude but effective B-V color-index -> RGB tint: hot/blue stars (low/negative index) skew
|
||||
* blue-white, cool/red stars (high index) skew orange-red, matching real spectral colors.
|
||||
*
|
||||
* `colorIndex` is `null` for the ~10% of stars HYG never photometered. Those fall back to a
|
||||
* value derived from `spectralType`, and to neutral white only when the catalog records no
|
||||
* classification at all — never to 0, which is itself a real color index meaning "hot A-type"
|
||||
* and would paint several hundred red dwarfs blue-white.
|
||||
*/
|
||||
export function colorIndexToRgb(colorIndex: number): THREE.Color {
|
||||
const t = THREE.MathUtils.clamp((colorIndex + 0.4) / 2.4, 0, 1);
|
||||
export function colorIndexToRgb(colorIndex: number | null, spectralType?: string): THREE.Color {
|
||||
const resolved = colorIndex ?? spectralTypeToColorIndex(spectralType);
|
||||
const color = new THREE.Color();
|
||||
if (resolved === null) {
|
||||
return color.copy(NEUTRAL_STAR_COLOR);
|
||||
}
|
||||
|
||||
const t = THREE.MathUtils.clamp((resolved + 0.4) / 2.4, 0, 1);
|
||||
return t < 0.5 ? color.lerpColors(COLD_STAR_COLOR, NEUTRAL_STAR_COLOR, t * 2) : color.lerpColors(NEUTRAL_STAR_COLOR, WARM_STAR_COLOR, (t - 0.5) * 2);
|
||||
}
|
||||
|
||||
/** Brighter stars (lower apparent magnitude) render as bigger points. */
|
||||
function magnitudeToPointSize(magnitude: number): number {
|
||||
export function magnitudeToPointSize(magnitude: number): number {
|
||||
const t = THREE.MathUtils.clamp(1 - (magnitude + 2) / 12, 0, 1);
|
||||
return MIN_POINT_SIZE + t * (MAX_POINT_SIZE - MIN_POINT_SIZE);
|
||||
}
|
||||
|
||||
/** A unit quad centred on the origin — the billboard every star instance is drawn on. */
|
||||
function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometry {
|
||||
const geometry = new THREE.InstancedBufferGeometry();
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
new THREE.BufferAttribute(new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]), 3)
|
||||
);
|
||||
geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), 2));
|
||||
geometry.setIndex([0, 1, 2, 0, 2, 3]);
|
||||
geometry.instanceCount = instanceCount;
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a `THREE.Points` field from the ETL-generated star positions/index, using a TSL
|
||||
* `PointsNodeMaterial` whose color/size are driven by per-vertex attributes derived from
|
||||
* each star's spectral color index and magnitude.
|
||||
* Builds the galaxy-scale star field as instanced camera-facing billboards, one per HYG star,
|
||||
* coloured by spectral index and sized by magnitude.
|
||||
*
|
||||
* Note: per the Three.js WebGPU backend, point primitives are capped at 1px on native
|
||||
* WebGPU — `sizeNode` only has a visible effect when `WebGPURenderer` has fallen back to
|
||||
* its WebGL2 backend. Color variation works on both backends.
|
||||
* **Why billboards and not `THREE.Points`.** Point primitives are capped at a single pixel on
|
||||
* the WebGPU backend — which is the renderer this app targets — so a points cloud rendered
|
||||
* every star as an identical 1 px dot no matter what `sizeNode` said, discarding both the
|
||||
* magnitude sizing and any glow. Instanced quads render identically on both backends.
|
||||
*
|
||||
* `SpriteNodeMaterial` takes each instance's centre from `positionNode` rather than from an
|
||||
* instance matrix (see its own documentation), so the per-star data rides on instanced buffer
|
||||
* attributes and the mesh itself never moves.
|
||||
*
|
||||
* Sizes are angular (`sizeAttenuation = false`), so a star holds the same apparent size however
|
||||
* close the camera gets. That is deliberate and physically right: real stars are unresolvable
|
||||
* point sources, and their apparent size on screen is a function of brightness, not distance.
|
||||
*/
|
||||
/**
|
||||
* Chooses which stars to draw when the catalogue is larger than the budget: everything inside
|
||||
* the neighbourhood radius, then the brightest of the rest until the budget is spent.
|
||||
*
|
||||
* Returns indices into the original list, so the caller can subset the positions that go with
|
||||
* them. Returns them in catalogue order rather than in selection order, purely so the drawn set
|
||||
* is stable and inspectable.
|
||||
*/
|
||||
/**
|
||||
* Reads a render budget override off the page URL (`?stars=20000`), falling back to the default.
|
||||
*
|
||||
* Two uses, one real and one incidental. The real one is a deployment or a machine that cannot
|
||||
* draw the whole catalogue — a number in a URL beats a rebuild. The incidental one is the
|
||||
* end-to-end suite, which runs against a software rasterizer whose frame rate is two orders of
|
||||
* magnitude below a real GPU's: those tests are checking navigation and state, and making them
|
||||
* wait on a rasterizer measures nothing about the app.
|
||||
*/
|
||||
export function starRenderBudgetFromUrl(search: string, fallback = STAR_RENDER_BUDGET): number {
|
||||
const requested = Number(new URLSearchParams(search).get('stars'));
|
||||
return Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : fallback;
|
||||
}
|
||||
|
||||
export function selectDrawnStars(stars: readonly StarRecord[], budget = STAR_RENDER_BUDGET): Uint32Array {
|
||||
if (stars.length <= budget) {
|
||||
return Uint32Array.from(stars.keys());
|
||||
}
|
||||
|
||||
const near: number[] = [];
|
||||
const far: number[] = [];
|
||||
stars.forEach((star, index) => {
|
||||
(Math.hypot(star.x, star.y, star.z) <= ALWAYS_DRAWN_RADIUS_PC ? near : far).push(index);
|
||||
});
|
||||
|
||||
far.sort((a, b) => stars[a].magnitude - stars[b].magnitude);
|
||||
const selected = near.concat(far.slice(0, Math.max(0, budget - near.length)));
|
||||
selected.sort((a, b) => a - b);
|
||||
return Uint32Array.from(selected);
|
||||
}
|
||||
|
||||
export class StarFieldRenderer {
|
||||
readonly object: THREE.Points;
|
||||
readonly object: THREE.Mesh;
|
||||
/** How many of the catalogue's stars this field actually draws. */
|
||||
readonly drawnCount: number;
|
||||
|
||||
private readonly geometry: THREE.BufferGeometry;
|
||||
private readonly material: THREE.PointsNodeMaterial;
|
||||
private readonly geometry: THREE.InstancedBufferGeometry;
|
||||
private readonly material: THREE.SpriteNodeMaterial;
|
||||
/** The subset of the catalogue that is drawn, and so the only set that can be clicked. */
|
||||
private readonly stars: readonly StarRecord[];
|
||||
/** Angular diameter per drawn star, in the same order as `stars` — reused for picking. */
|
||||
private readonly angularSizes: Float32Array;
|
||||
|
||||
constructor(private readonly stars: readonly StarRecord[], positions: Float32Array) {
|
||||
this.geometry = new THREE.BufferGeometry();
|
||||
this.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
constructor(catalogue: readonly StarRecord[], cataloguePositions: Float32Array, budget = STAR_RENDER_BUDGET) {
|
||||
const drawn = selectDrawnStars(catalogue, budget);
|
||||
this.stars = drawn.length === catalogue.length ? catalogue : Array.from(drawn, (index) => catalogue[index]);
|
||||
this.drawnCount = this.stars.length;
|
||||
|
||||
const stars = this.stars;
|
||||
this.geometry = createQuadGeometry(stars.length);
|
||||
|
||||
const colors = new Float32Array(stars.length * 3);
|
||||
const sizes = new Float32Array(stars.length);
|
||||
this.angularSizes = new Float32Array(stars.length);
|
||||
// Repacked only when the drawn set is a subset; otherwise the ETL's buffer is used as-is.
|
||||
const positions =
|
||||
drawn.length === catalogue.length
|
||||
? cataloguePositions
|
||||
: Float32Array.from({ length: drawn.length * 3 }, (_, i) => cataloguePositions[drawn[(i / 3) | 0] * 3 + (i % 3)]);
|
||||
|
||||
stars.forEach((star, index) => {
|
||||
const color = colorIndexToRgb(star.colorIndex);
|
||||
const color = colorIndexToRgb(star.colorIndex, star.spectralType);
|
||||
colors[index * 3] = color.r;
|
||||
colors[index * 3 + 1] = color.g;
|
||||
colors[index * 3 + 2] = color.b;
|
||||
sizes[index] = magnitudeToPointSize(star.magnitude);
|
||||
this.angularSizes[index] = magnitudeToPointSize(star.magnitude) * PIXELS_TO_ANGULAR_SIZE;
|
||||
});
|
||||
|
||||
this.geometry.setAttribute('starColor', new THREE.BufferAttribute(colors, 3));
|
||||
this.geometry.setAttribute('starSize', new THREE.BufferAttribute(sizes, 1));
|
||||
const positionAttribute = new THREE.InstancedBufferAttribute(positions, 3);
|
||||
const colorAttribute = new THREE.InstancedBufferAttribute(colors, 3);
|
||||
const sizeAttribute = new THREE.InstancedBufferAttribute(this.angularSizes, 1);
|
||||
|
||||
this.material = new THREE.PointsNodeMaterial({
|
||||
colorNode: attribute('starColor', 'vec3'),
|
||||
sizeNode: attribute('starSize', 'float'),
|
||||
sizeAttenuation: true,
|
||||
this.material = new THREE.SpriteNodeMaterial({
|
||||
transparent: true,
|
||||
depthWrite: false
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
this.material.sizeAttenuation = false;
|
||||
this.material.positionNode = instancedBufferAttribute(positionAttribute, 'vec3');
|
||||
this.material.scaleNode = instancedBufferAttribute(sizeAttribute, 'float');
|
||||
this.material.colorNode = instancedBufferAttribute(colorAttribute, 'vec3');
|
||||
// Soft radial falloff so each star is a small bright core inside a halo, rather than a
|
||||
// hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre.
|
||||
const radius = uv().sub(vec2(0.5)).length();
|
||||
this.material.opacityNode = smoothstep(0.0, 0.5, radius).oneMinus().pow(2.0);
|
||||
|
||||
this.object = new THREE.Points(this.geometry, this.material);
|
||||
this.object = new THREE.Mesh(this.geometry, this.material);
|
||||
// The quad's own bounds sit at the origin and say nothing about where the instances are,
|
||||
// so leaving culling on would drop the whole field whenever the origin left the frustum.
|
||||
this.object.frustumCulled = false;
|
||||
}
|
||||
|
||||
/** Looks up the HYG star id for a given geometry vertex index (e.g. from a raycast hit). */
|
||||
starIdAt(vertexIndex: number): number | undefined {
|
||||
return this.stars[vertexIndex]?.id;
|
||||
/** Looks up the HYG star id for a given instance index. */
|
||||
starIdAt(instanceIndex: number): number | undefined {
|
||||
return this.stars[instanceIndex]?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The star under `pointerNdc`, or `undefined`.
|
||||
*
|
||||
* Billboarding happens in the vertex shader, so the CPU-side geometry is a single quad at the
|
||||
* origin and `Raycaster` cannot see the star field at all. Picking is therefore done in screen
|
||||
* space, which is also strictly better than the fixed world-space radius the points cloud
|
||||
* needed: each star is tested against the size it is actually drawn at, so the hit area matches
|
||||
* what the user sees at every zoom level instead of being over-permissive up close and
|
||||
* sub-pixel at the far end of the camera's range.
|
||||
*/
|
||||
pickAt(pointerNdc: THREE.Vector2, camera: THREE.PerspectiveCamera): number | undefined {
|
||||
const tanHalfFov = Math.tan((camera.fov * Math.PI) / 360);
|
||||
const projected = new THREE.Vector3();
|
||||
|
||||
let bestIndex: number | undefined;
|
||||
let bestScore = Infinity;
|
||||
|
||||
for (let index = 0; index < this.stars.length; index++) {
|
||||
const star = this.stars[index];
|
||||
projected.set(star.x, star.y, star.z).project(camera);
|
||||
// Outside the depth range means behind the camera or beyond the far plane; `project`
|
||||
// mirrors points behind the camera onto the screen, so this guard is load-bearing.
|
||||
if (projected.z < -1 || projected.z > 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
|
||||
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
|
||||
const ndcRadius = (0.5 * this.angularSizes[index]) / tanHalfFov + PICK_NDC_SLOP;
|
||||
const dx = (projected.x - pointerNdc.x) * camera.aspect;
|
||||
const dy = projected.y - pointerNdc.y;
|
||||
const score = Math.hypot(dx, dy) / ndcRadius;
|
||||
|
||||
if (score <= 1 && score < bestScore) {
|
||||
bestScore = score;
|
||||
bestIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex === undefined ? undefined : this.stars[bestIndex].id;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { StarLabelOverlay } from './star-label-overlay';
|
||||
|
||||
describe('StarLabelOverlay', () => {
|
||||
let scene: THREE.Scene;
|
||||
let overlay: StarLabelOverlay;
|
||||
let camera: THREE.PerspectiveCamera;
|
||||
|
||||
/**
|
||||
* Every label element currently in the layer, in DOM order.
|
||||
*
|
||||
* `CSS2DRenderer` only attaches an element to its layer when it projects it, so the labels do
|
||||
* not exist in the DOM until something has been rendered — which is why this draws a frame
|
||||
* rather than reading straight off `domElement`.
|
||||
*/
|
||||
function labels(): HTMLElement[] {
|
||||
overlay.render(camera);
|
||||
return [...overlay.domElement.querySelectorAll<HTMLElement>('.map-label')];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new THREE.Scene();
|
||||
overlay = new StarLabelOverlay(scene);
|
||||
overlay.setSize(800, 600);
|
||||
camera = new THREE.PerspectiveCamera(50, 800 / 600, 0.1, 1000);
|
||||
camera.position.set(0, 0, 20);
|
||||
camera.updateMatrixWorld();
|
||||
});
|
||||
|
||||
it('prints what a thing is under what it is called', () => {
|
||||
overlay.update([{ id: 1, name: 'Sirius', kind: 'System', x: 1, y: 2, z: 3 }]);
|
||||
|
||||
const label = labels()[0];
|
||||
expect(label.querySelector('.map-label-name')?.textContent).toBe('Sirius');
|
||||
expect(label.querySelector('.map-label-kind')?.textContent).toBe('System');
|
||||
});
|
||||
|
||||
it('prints the name alone when there is no type to give', () => {
|
||||
// The second line is optional, and an empty one would still cost its line height — which
|
||||
// over a screen of labels shifts every name off the point it is anchored to.
|
||||
overlay.update([{ id: 1, name: 'Sirius', x: 1, y: 2, z: 3 }]);
|
||||
|
||||
expect(labels()[0].querySelector('.map-label-name')?.textContent).toBe('Sirius');
|
||||
expect(labels()[0].querySelector('.map-label-kind')).toBeNull();
|
||||
});
|
||||
|
||||
it('places the label at the point it belongs to', () => {
|
||||
overlay.update([{ id: 7, name: 'Vega', kind: 'Star', x: 4, y: -5, z: 6 }]);
|
||||
|
||||
const object = scene.children.find((child) => child.type === 'Object3D');
|
||||
expect(object?.position.toArray()).toEqual([4, -5, 6]);
|
||||
});
|
||||
|
||||
it('touches the DOM only for labels that actually changed', () => {
|
||||
overlay.update([
|
||||
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
|
||||
{ id: 2, name: 'Vega', kind: 'Star', x: 0, y: 1, z: 0 }
|
||||
]);
|
||||
const sirius = labels()[0];
|
||||
|
||||
overlay.update([
|
||||
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
|
||||
{ id: 3, name: 'Altair', kind: 'Star', x: 0, y: 0, z: 1 }
|
||||
]);
|
||||
|
||||
// Same element instance: the label that stayed was not torn down and rebuilt.
|
||||
expect(labels()[0]).toBe(sirius);
|
||||
expect(labels().map((label) => label.querySelector('.map-label-name')?.textContent)).toEqual(['Sirius', 'Altair']);
|
||||
});
|
||||
|
||||
it('drops every label when given none', () => {
|
||||
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 }]);
|
||||
overlay.update([]);
|
||||
|
||||
expect(labels()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('leaves nothing behind when disposed', () => {
|
||||
overlay.update([
|
||||
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
|
||||
{ id: 'ngc:224', name: 'Andromeda', kind: 'GALAXY', x: 0, y: 1, z: 0 }
|
||||
]);
|
||||
overlay.dispose();
|
||||
|
||||
expect(labels()).toHaveLength(0);
|
||||
expect(scene.children).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,19 @@ import * as THREE from 'three/webgpu';
|
||||
import { CSS2DObject, CSS2DRenderer } from 'three/addons/renderers/CSS2DRenderer.js';
|
||||
|
||||
export interface LabeledPoint {
|
||||
id: number;
|
||||
/** Numeric for HYG stars, string for catalog designations such as deep-sky objects. */
|
||||
id: number | string;
|
||||
name: string;
|
||||
/**
|
||||
* What sort of thing this is — `STAR`, `PLANET`, `NEBULA`, `ARM`. Printed under the name in
|
||||
* smaller, dimmer, wider-tracked capitals.
|
||||
*
|
||||
* A name on its own is ambiguous in a map that mixes scales: "Orion" is an arm, a nebula and a
|
||||
* constellation, and at a glance nothing distinguishes the label on one from the label on
|
||||
* another. The second line is what makes a label say what it is pointing at, not just what it
|
||||
* is called.
|
||||
*/
|
||||
kind?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
@@ -19,7 +30,7 @@ export class StarLabelOverlay {
|
||||
readonly domElement: HTMLElement;
|
||||
|
||||
private readonly cssRenderer = new CSS2DRenderer();
|
||||
private readonly labelObjects = new Map<number, CSS2DObject>();
|
||||
private readonly labelObjects = new Map<number | string, CSS2DObject>();
|
||||
|
||||
constructor(private readonly scene: THREE.Scene) {
|
||||
this.cssRenderer.domElement.classList.add('star-label-layer');
|
||||
@@ -30,7 +41,13 @@ export class StarLabelOverlay {
|
||||
this.cssRenderer.setSize(width, height);
|
||||
}
|
||||
|
||||
/** Shows exactly these labels, adding/removing DOM elements only for a changed set. */
|
||||
/**
|
||||
* Shows exactly these labels, adding/removing DOM elements only for a changed set.
|
||||
*
|
||||
* A label that is already up is repositioned rather than left where it was: stars never move,
|
||||
* but planets do, and a system's labels would otherwise stay pinned to wherever each body
|
||||
* happened to be when its label first appeared.
|
||||
*/
|
||||
update(points: readonly LabeledPoint[]): void {
|
||||
const idsToShow = new Set(points.map((point) => point.id));
|
||||
|
||||
@@ -41,7 +58,10 @@ export class StarLabelOverlay {
|
||||
}
|
||||
|
||||
for (const point of points) {
|
||||
if (!this.labelObjects.has(point.id)) {
|
||||
const existing = this.labelObjects.get(point.id);
|
||||
if (existing) {
|
||||
existing.position.set(point.x, point.y, point.z);
|
||||
} else {
|
||||
this.addLabel(point);
|
||||
}
|
||||
}
|
||||
@@ -61,8 +81,19 @@ export class StarLabelOverlay {
|
||||
const element = document.createElement('div');
|
||||
// Tailwind utility classes assigned directly since this element lives outside Angular's
|
||||
// view encapsulation (see the class comment above) rather than through a component template.
|
||||
element.className = 'translate-x-1.5 -translate-y-1.5 whitespace-nowrap font-body text-[11px] text-accent [text-shadow:0_0_4px_rgba(0,0,0,0.9)]';
|
||||
element.textContent = point.name;
|
||||
element.className = 'map-label translate-x-1.5 -translate-y-1.5 whitespace-nowrap font-body';
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'map-label-name';
|
||||
name.textContent = point.name;
|
||||
element.appendChild(name);
|
||||
|
||||
if (point.kind) {
|
||||
const kind = document.createElement('span');
|
||||
kind.className = 'map-label-kind';
|
||||
kind.textContent = point.kind;
|
||||
element.appendChild(kind);
|
||||
}
|
||||
|
||||
const object = new CSS2DObject(element);
|
||||
object.position.set(point.x, point.y, point.z);
|
||||
@@ -70,7 +101,7 @@ export class StarLabelOverlay {
|
||||
this.labelObjects.set(point.id, object);
|
||||
}
|
||||
|
||||
private removeLabel(id: number, object: CSS2DObject): void {
|
||||
private removeLabel(id: number | string, object: CSS2DObject): void {
|
||||
this.scene.remove(object);
|
||||
object.element.remove();
|
||||
this.labelObjects.delete(id);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ViewLevel } from '../../shared/state/navigation.store';
|
||||
import { StarmapHudComponent } from './starmap-hud.component';
|
||||
|
||||
describe('StarmapHudComponent', () => {
|
||||
let fixture: ComponentFixture<StarmapHudComponent>;
|
||||
|
||||
function render(level: ViewLevel): HTMLElement {
|
||||
fixture.componentRef.setInput('level', level);
|
||||
fixture.detectChanges();
|
||||
return fixture.nativeElement as HTMLElement;
|
||||
}
|
||||
|
||||
function buttonLabels(host: HTMLElement): string[] {
|
||||
return [...host.querySelectorAll('nav button')].map((button) => button.textContent?.trim() ?? '');
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({ imports: [StarmapHudComponent] }).compileComponents();
|
||||
fixture = TestBed.createComponent(StarmapHudComponent);
|
||||
});
|
||||
|
||||
it('offers the way back down from the outermost scale', () => {
|
||||
// The two outer scales share one space, so the ladder goes both ways between them.
|
||||
expect(buttonLabels(render('galactic'))).toEqual(['Solar Neighbourhood']);
|
||||
});
|
||||
|
||||
it('offers the Milky Way from the solar neighbourhood', () => {
|
||||
expect(buttonLabels(render('galaxy'))).toEqual(['Milky Way']);
|
||||
});
|
||||
|
||||
it('offers both wider scales from inside a system', () => {
|
||||
expect(buttonLabels(render('system'))).toEqual(['Milky Way', 'Solar Neighbourhood']);
|
||||
});
|
||||
|
||||
it('never offers the system scale, which needs a star picked first', () => {
|
||||
for (const level of ['galactic', 'galaxy', 'system'] as const) {
|
||||
expect(buttonLabels(render(level))).not.toContain('System');
|
||||
}
|
||||
});
|
||||
|
||||
it('marks the current scale rather than making it a button that goes nowhere', () => {
|
||||
// Load-bearing beyond tidiness: this marker is how the end-to-end tests tell which scale the
|
||||
// view has actually settled at.
|
||||
const host = render('galaxy');
|
||||
const current = host.querySelector('[data-testid="hud-current-level"]');
|
||||
expect(current?.textContent?.trim()).toBe('Solar Neighbourhood');
|
||||
expect(current?.getAttribute('aria-current')).toBe('step');
|
||||
expect(buttonLabels(host)).not.toContain('Solar Neighbourhood');
|
||||
});
|
||||
|
||||
it('marks exactly one scale as current', () => {
|
||||
for (const level of ['galactic', 'galaxy', 'system'] as const) {
|
||||
expect(render(level).querySelectorAll('[data-testid="hud-current-level"]')).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('emits the scale that was asked for', () => {
|
||||
const host = render('system');
|
||||
const emitted: ViewLevel[] = [];
|
||||
fixture.componentInstance.levelSelected.subscribe((level) => emitted.push(level));
|
||||
|
||||
host.querySelectorAll('nav button').forEach((button) => (button as HTMLButtonElement).click());
|
||||
|
||||
expect(emitted).toEqual(['galactic', 'galaxy']);
|
||||
});
|
||||
|
||||
it('renders the readout panel from its inputs', () => {
|
||||
fixture.componentRef.setInput('level', 'galactic');
|
||||
fixture.componentRef.setInput('eyebrow', 'Galactic Scale');
|
||||
fixture.componentRef.setInput('title', 'Milky Way');
|
||||
fixture.componentRef.setInput('subtitle', 'Barred spiral');
|
||||
fixture.componentRef.setInput('readouts', [{ label: 'Arms', value: '5' }]);
|
||||
fixture.componentRef.setInput('note', 'Illustrative model.');
|
||||
fixture.componentRef.setInput('range', '21.5 kpc');
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||
for (const expected of ['Galactic Scale', 'Milky Way', 'Barred spiral', 'Arms', '5', 'Illustrative model.', '21.5 kpc']) {
|
||||
expect(text).toContain(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves out the optional lines it was given nothing for', () => {
|
||||
const host = render('galaxy');
|
||||
expect(host.querySelector('dl')).toBeNull();
|
||||
expect(host.textContent).not.toContain('undefined');
|
||||
});
|
||||
|
||||
it('names what the view is holding on the banner across the top', () => {
|
||||
fixture.componentRef.setInput('level', 'system');
|
||||
fixture.componentRef.setInput('title', 'Sol');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.hud-banner')?.textContent?.trim()).toBe('Sol');
|
||||
});
|
||||
|
||||
it('shows no banner when the view is holding nothing', () => {
|
||||
// An empty nameplate is worse than none: it reads as a selection that failed to resolve.
|
||||
expect(render('galaxy').querySelector('.hud-banner')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||
|
||||
import { ViewLevel } from '../../shared/state/navigation.store';
|
||||
|
||||
export interface HudReadout {
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
interface LadderStep {
|
||||
readonly level: ViewLevel;
|
||||
readonly label: string;
|
||||
/** Above the active step, on it, or below it — which is what the step is styled from. */
|
||||
readonly state: 'above' | 'current' | 'below';
|
||||
/** Whether this step is somewhere the view can be sent right now. */
|
||||
readonly reachable: boolean;
|
||||
}
|
||||
|
||||
/** Outermost first — the order the ladder is drawn in, and the order levels nest. */
|
||||
const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
||||
{ level: 'galactic', label: 'Milky Way' },
|
||||
{ level: 'galaxy', label: 'Solar Neighbourhood' },
|
||||
{ level: 'system', label: 'System' }
|
||||
];
|
||||
|
||||
/**
|
||||
* The map's heads-up display: the scale ladder down the left, the readout panel across the
|
||||
* bottom, a centre reticle on whatever the camera is holding, and the frame brackets around
|
||||
* the whole viewport.
|
||||
*
|
||||
* Purely presentational — every value arrives as an input and the only thing it emits is a
|
||||
* request to move to another scale. The scene owns the camera and decides what that means.
|
||||
*
|
||||
* Reachable levels render as buttons and the current one renders as a static marker rather than
|
||||
* a button that does nothing. The galactic and neighbourhood scales are always reachable, in
|
||||
* both directions: they are one continuous space and the Sun is always in it. The system scale
|
||||
* is not, since there is no system to go to until a star has been picked.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-starmap-hud',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'pointer-events-none absolute inset-0 block select-none' },
|
||||
template: `
|
||||
<div class="hud-frame absolute inset-0"></div>
|
||||
<div class="hud-vignette absolute inset-0"></div>
|
||||
|
||||
@if (showReticle()) {
|
||||
<!-- A hexagon rather than a square bracket: the shape reads as a sensor lock on a body,
|
||||
and stays distinct from the rectangular panel chrome everywhere else on screen. -->
|
||||
<svg class="absolute top-1/2 left-1/2 h-14 w-14 -translate-x-1/2 -translate-y-1/2 text-accent/70" viewBox="0 0 56 56" fill="none" aria-hidden="true">
|
||||
<polygon points="28,4 48,16 48,40 28,52 8,40 8,16" stroke="currentColor" stroke-width="1" />
|
||||
<path d="M28 22v-6M28 40v-6M22 28h-6M40 28h-6" stroke="currentColor" stroke-width="1" opacity="0.8" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
<!-- Top rail: which scale the view is at, and what it is holding. Both sit on one line
|
||||
across the top of the display, clear of the search field above them. -->
|
||||
<nav aria-label="Map scale" class="pointer-events-auto absolute top-16 left-6 flex items-stretch gap-px">
|
||||
@for (step of ladder(); track step.level) {
|
||||
@if (step.reachable) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="levelSelected.emit(step.level)"
|
||||
class="hud-tab border-y border-border/70 bg-panel/70 px-4 py-1.5 font-display text-[10px] tracking-[0.22em] text-muted uppercase backdrop-blur-sm transition-colors hover:bg-accent/15 hover:text-accent focus:bg-accent/15 focus:text-accent focus:outline-none"
|
||||
>
|
||||
{{ step.label }}
|
||||
</button>
|
||||
} @else {
|
||||
<span
|
||||
[attr.aria-current]="step.state === 'current' ? 'step' : null"
|
||||
[attr.data-testid]="step.state === 'current' ? 'hud-current-level' : null"
|
||||
class="hud-tab border-y px-4 py-1.5 font-display text-[10px] tracking-[0.22em] uppercase backdrop-blur-sm"
|
||||
[class]="step.state === 'current' ? 'border-accent/70 bg-accent/20 text-accent' : 'border-border/40 bg-panel/40 text-border'"
|
||||
>{{ step.label }}</span
|
||||
>
|
||||
}
|
||||
}
|
||||
</nav>
|
||||
|
||||
@if (title()) {
|
||||
<div class="absolute top-16 left-1/2 -translate-x-1/2">
|
||||
<div class="hud-banner flex items-center gap-2.5 border-b border-accent/60 bg-panel/75 px-6 py-1.5 backdrop-blur-sm">
|
||||
<svg class="h-3 w-3 shrink-0 text-accent" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<polygon points="6,1 10.5,3.5 10.5,8.5 6,11 1.5,8.5 1.5,3.5" stroke="currentColor" stroke-width="1" />
|
||||
<circle cx="6" cy="6" r="1.4" fill="currentColor" />
|
||||
</svg>
|
||||
<span class="font-display text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="absolute right-6 bottom-6 left-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div class="hud-panel max-w-lg px-4 py-3">
|
||||
<p class="font-display text-[10px] tracking-[0.28em] text-muted uppercase">{{ eyebrow() }}</p>
|
||||
<p data-testid="hud-title" class="mt-1 font-display text-xl tracking-[0.06em] text-text">{{ title() }}</p>
|
||||
@if (subtitle()) {
|
||||
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
|
||||
}
|
||||
@if (readouts().length) {
|
||||
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
||||
@for (readout of readouts(); track readout.label) {
|
||||
<div>
|
||||
<dt class="text-[10px] tracking-[0.18em] text-muted uppercase">{{ readout.label }}</dt>
|
||||
<dd class="text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
@if (note()) {
|
||||
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed tracking-[0.08em] text-muted uppercase">{{ note() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="hud-panel px-4 py-3 text-right">
|
||||
<p class="font-display text-[10px] tracking-[0.28em] text-muted uppercase">Range</p>
|
||||
<p class="mt-1 font-display text-lg text-accent tabular-nums">{{ range() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class StarmapHudComponent {
|
||||
readonly level = input.required<ViewLevel>();
|
||||
/** Headline for the readout panel — the selected star, or the name of the current scale. */
|
||||
readonly title = input('');
|
||||
readonly subtitle = input('');
|
||||
readonly eyebrow = input('');
|
||||
readonly readouts = input<readonly HudReadout[]>([]);
|
||||
/** Standing caveat for the current view, e.g. that galactic structure is a model. */
|
||||
readonly note = input('');
|
||||
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
|
||||
readonly range = input('');
|
||||
readonly showReticle = input(true);
|
||||
|
||||
readonly levelSelected = output<ViewLevel>();
|
||||
|
||||
readonly ladder = computed<readonly LadderStep[]>(() => {
|
||||
const activeIndex = LADDER.findIndex((step) => step.level === this.level());
|
||||
return LADDER.map((step, index) => ({
|
||||
...step,
|
||||
state: index < activeIndex ? 'above' : index === activeIndex ? 'current' : 'below',
|
||||
// Every scale is somewhere the camera can be sent except the current one and the system
|
||||
// level, which needs a star picked first — there is no "the system" without a selection.
|
||||
reachable: index !== activeIndex && step.level !== 'system'
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
||||
import {
|
||||
bodyMarkerRadiusAu,
|
||||
DEFAULT_STAR_MARKER_RADIUS_AU,
|
||||
starGlowExtentAu,
|
||||
starMarkerRadiusAu,
|
||||
systemFrameRadiusAu,
|
||||
systemFramingDistanceAu,
|
||||
systemGridRingsAu,
|
||||
SystemViewport,
|
||||
SYSTEM_VIEW_DIRECTION_IN_PLANE,
|
||||
systemViewDirection
|
||||
} from './system-framing';
|
||||
|
||||
/** Real systems spanning the range the view has to cope with. */
|
||||
const TRAPPIST_1 = { innermost: 0.01154, outermost: 0.06189 };
|
||||
const GL_357 = { innermost: 0.035, outermost: 0.204 };
|
||||
const SOLAR = { innermost: 0.387, outermost: 30.07 };
|
||||
|
||||
describe('starMarkerRadiusAu', () => {
|
||||
it('never reaches the innermost orbit', () => {
|
||||
for (const { innermost } of [TRAPPIST_1, GL_357, SOLAR]) {
|
||||
expect(starMarkerRadiusAu(innermost)).toBeLessThan(innermost);
|
||||
}
|
||||
});
|
||||
|
||||
it('shrinks to fit a compact system whose orbits were all inside the old fixed radius', () => {
|
||||
// Every TRAPPIST-1 orbit is inside 0.2 AU, so the star used to swallow the entire system.
|
||||
expect(starMarkerRadiusAu(TRAPPIST_1.innermost)).toBeLessThan(TRAPPIST_1.outermost);
|
||||
expect(starMarkerRadiusAu(GL_357.innermost)).toBeLessThan(GL_357.outermost);
|
||||
});
|
||||
|
||||
it('never grows beyond the default, however wide the system', () => {
|
||||
expect(starMarkerRadiusAu(SOLAR.innermost)).toBeLessThanOrEqual(DEFAULT_STAR_MARKER_RADIUS_AU);
|
||||
expect(starMarkerRadiusAu(500)).toBe(DEFAULT_STAR_MARKER_RADIUS_AU);
|
||||
});
|
||||
|
||||
it('scales in proportion to the innermost orbit', () => {
|
||||
expect(starMarkerRadiusAu(0.02) / starMarkerRadiusAu(0.01)).toBeCloseTo(2, 9);
|
||||
});
|
||||
|
||||
it('falls back to the default when there are no planets to scale against', () => {
|
||||
for (const innermost of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
expect(starMarkerRadiusAu(innermost)).toBe(DEFAULT_STAR_MARKER_RADIUS_AU);
|
||||
}
|
||||
});
|
||||
|
||||
it('stays positive for an extremely tight orbit', () => {
|
||||
expect(starMarkerRadiusAu(0.0001)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('systemFramingDistanceAu', () => {
|
||||
it('fits the radius it is given in view, with room around it', () => {
|
||||
for (const radius of [TRAPPIST_1.outermost, GL_357.outermost, SOLAR.outermost]) {
|
||||
expect(systemFrameRadiusAu(systemFramingDistanceAu(radius))).toBeGreaterThan(radius);
|
||||
}
|
||||
});
|
||||
|
||||
it('closes right in on a compact system instead of hanging back at a fixed floor', () => {
|
||||
// The old floor was 3 AU — some 48x the width of the entire TRAPPIST-1 system.
|
||||
expect(systemFramingDistanceAu(TRAPPIST_1.outermost)).toBeLessThan(1);
|
||||
expect(systemFramingDistanceAu(GL_357.outermost)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('scales in proportion to the radius it has to frame', () => {
|
||||
expect(systemFramingDistanceAu(0.2) / systemFramingDistanceAu(0.1)).toBeCloseTo(2, 9);
|
||||
});
|
||||
|
||||
it('backs off further for a narrower field of view, which a fixed multiple could not', () => {
|
||||
// The bug this replaced: the multiple was tuned by eye against a 55-degree field and the
|
||||
// engine's camera is 50, so everything sat that much too close.
|
||||
const wide = systemFramingDistanceAu(1, { fovDegrees: 70, aspect: 1.78 });
|
||||
const narrow = systemFramingDistanceAu(1, { fovDegrees: 30, aspect: 1.78 });
|
||||
expect(narrow).toBeGreaterThan(wide);
|
||||
});
|
||||
|
||||
it('backs off further for a portrait window, where the horizontal axis is the tighter one', () => {
|
||||
const landscape = systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 1.78 });
|
||||
const portrait = systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 0.6 });
|
||||
expect(portrait).toBeCloseTo(landscape / 0.6, 6);
|
||||
});
|
||||
|
||||
it('ignores aspect once the window is landscape, since the vertical binds there', () => {
|
||||
const square = systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 1 });
|
||||
expect(systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 2.5 })).toBeCloseTo(square, 9);
|
||||
});
|
||||
|
||||
it('caps the distance so a far-flung companion cannot shrink the star to nothing', () => {
|
||||
expect(systemFramingDistanceAu(1000)).toBe(systemFramingDistanceAu(5000));
|
||||
});
|
||||
|
||||
it('reaches far enough to frame the solar system out to Pluto', () => {
|
||||
// The old 80 AU ceiling could not: at the camera's real field of view this needs 120.
|
||||
const rings = systemGridRingsAu(39.288);
|
||||
const distance = systemFramingDistanceAu(rings[rings.length - 1]);
|
||||
expect(distance).toBeLessThan(200);
|
||||
expect(systemFrameRadiusAu(distance)).toBeGreaterThan(39.288);
|
||||
});
|
||||
|
||||
it('stays outside the orbit controls minimum distance', () => {
|
||||
// Framing closer than the controls allow would be clamped straight back out again.
|
||||
expect(systemFramingDistanceAu(0.00001)).toBeGreaterThanOrEqual(0.05);
|
||||
});
|
||||
|
||||
it('uses a sensible default for a star with no known planets', () => {
|
||||
for (const radius of [0, -1, Number.NaN]) {
|
||||
expect(systemFramingDistanceAu(radius)).toBe(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('starGlowExtentAu', () => {
|
||||
/** A typical viewport, so a screen-space claim can be made in pixels rather than in ratios. */
|
||||
const REFERENCE_VIEWPORT_HALF_HEIGHT_PX = 450;
|
||||
|
||||
/** The halo's visual radius, in AU, at the distance this system is framed from. */
|
||||
function haloRadiusAu(innermostAu: number, outermostAu: number, glowScale = 1): number {
|
||||
// The sprite's extent is its full width, so half of it is what reaches out from the star.
|
||||
return starGlowExtentAu(starMarkerRadiusAu(innermostAu), frameRadiusFor(outermostAu), glowScale) / 2;
|
||||
}
|
||||
|
||||
function frameRadiusFor(outermostAu: number): number {
|
||||
const rings = systemGridRingsAu(outermostAu);
|
||||
return systemFrameRadiusAu(systemFramingDistanceAu(rings[rings.length - 1]));
|
||||
}
|
||||
|
||||
/** Apparent size on screen, as a fraction of the frame's half-height. */
|
||||
function apparentFraction(innermostAu: number, outermostAu: number, glowScale = 1): number {
|
||||
return haloRadiusAu(innermostAu, outermostAu, glowScale) / frameRadiusFor(outermostAu);
|
||||
}
|
||||
|
||||
function apparentPixels(innermostAu: number, outermostAu: number): number {
|
||||
return apparentFraction(innermostAu, outermostAu) * REFERENCE_VIEWPORT_HALF_HEIGHT_PX;
|
||||
}
|
||||
|
||||
it('scales with the star for a compact system, where the star is already big enough', () => {
|
||||
// A tight frame relative to the star, so the star's own multiple is what decides.
|
||||
const marker = 0.02;
|
||||
const tightFrame = 0.5;
|
||||
expect(starGlowExtentAu(marker, tightFrame)).toBeCloseTo(marker * 3.2, 9);
|
||||
expect(starGlowExtentAu(marker * 2, tightFrame)).toBeCloseTo(marker * 2 * 3.2, 9);
|
||||
});
|
||||
|
||||
it('floors against the frame once the star would otherwise vanish into it', () => {
|
||||
// A star sized against a close-in orbit, framed from far enough out to hold a wide system:
|
||||
// the multiple of the star is nothing, so the frame decides instead.
|
||||
const tinyStar = 0.001;
|
||||
const wideFrame = 56;
|
||||
expect(starGlowExtentAu(tinyStar, wideFrame)).toBeGreaterThan(tinyStar * 3.2 * 100);
|
||||
});
|
||||
|
||||
it('keeps the Sun visible at the distance that frames the solar system', () => {
|
||||
// The case that prompted this: the solar system spans a factor of a hundred from Mercury to
|
||||
// Pluto, so a disc that stays clear of Mercury is about a pixel across once Pluto is in view.
|
||||
expect(apparentPixels(0.387, 39.288)).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it('leaves the inner orbits clear of the halo', () => {
|
||||
// The other half of the same trade. Venus and Earth have to stay legible as rings around the
|
||||
// star, which bounds the halo from above just as visibility bounds it from below.
|
||||
const halo = haloRadiusAu(0.387, 39.288);
|
||||
const VENUS_AU = 0.723;
|
||||
const EARTH_AU = 1;
|
||||
expect(halo).toBeLessThan(VENUS_AU);
|
||||
expect(halo).toBeLessThan(EARTH_AU);
|
||||
});
|
||||
|
||||
it('cannot clear Mercury as well, and does not pretend to', () => {
|
||||
// Mercury's orbit is 0.7% of the framed radius — about three pixels — so it is inside any
|
||||
// halo big enough to see. Pinned so the trade is a decision rather than an oversight.
|
||||
expect(haloRadiusAu(0.387, 39.288)).toBeGreaterThan(0.387);
|
||||
});
|
||||
|
||||
it('holds the floor across every system scale the datasets contain', () => {
|
||||
// A compact system's star is genuinely large relative to its own system and keeps the bigger
|
||||
// halo; the floor is not there to equalise them, only to stop the wide ones disappearing.
|
||||
for (const [innermost, outermost] of [
|
||||
[0.387, 39.288],
|
||||
[0.035, 0.204],
|
||||
[0.01154, 0.06189],
|
||||
[1.2, 12.4]
|
||||
]) {
|
||||
expect(apparentPixels(innermost, outermost)).toBeGreaterThan(4);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not blot out the system it sits in', () => {
|
||||
for (const [innermost, outermost] of [
|
||||
[0.387, 39.288],
|
||||
[0.035, 0.204],
|
||||
[0.01154, 0.06189]
|
||||
]) {
|
||||
expect(apparentFraction(innermost, outermost)).toBeLessThan(0.2);
|
||||
}
|
||||
});
|
||||
|
||||
it('dims for a star drawn from a colour rather than a photograph, but never below the floor', () => {
|
||||
// Above the floor the multiplier applies...
|
||||
expect(starGlowExtentAu(1, 10, 0.6)).toBeLessThan(starGlowExtentAu(1, 10, 1));
|
||||
// ...and at the floor it cannot dim a star into invisibility.
|
||||
expect(starGlowExtentAu(0.001, 56, 0.6)).toBe(starGlowExtentAu(0.001, 56, 1));
|
||||
});
|
||||
|
||||
it('falls back to the star alone when there is no frame to measure against', () => {
|
||||
for (const frame of [0, -1, Number.NaN]) {
|
||||
expect(starGlowExtentAu(0.2, frame)).toBeCloseTo(0.2 * 3.2, 9);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the grid and the framing together', () => {
|
||||
/** What the scene actually composes: rings from the orbits, then a distance from the rings. */
|
||||
function fit(outermostOrbitAu: number, viewport?: SystemViewport): { ring: number; frame: number } {
|
||||
const rings = systemGridRingsAu(outermostOrbitAu);
|
||||
const ring = rings[rings.length - 1];
|
||||
return { ring, frame: systemFrameRadiusAu(systemFramingDistanceAu(ring, viewport), viewport) };
|
||||
}
|
||||
|
||||
const VIEWPORTS: SystemViewport[] = [
|
||||
{ fovDegrees: 50, aspect: 1.78 },
|
||||
{ fovDegrees: 50, aspect: 1 },
|
||||
{ fovDegrees: 50, aspect: 0.6 }
|
||||
];
|
||||
|
||||
it('leaves the outermost ring clear of the frame edge at every scale and window shape', () => {
|
||||
// The whole point of framing against the grid rather than the orbits: before this, 368 of
|
||||
// the 371 systems in the datasets drew a grid wider than the view that was meant to hold it.
|
||||
for (const viewport of VIEWPORTS) {
|
||||
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) {
|
||||
const { ring, frame } = fit(outermost, viewport);
|
||||
expect(ring).toBeLessThan(frame);
|
||||
expect(ring / frame).toBeLessThan(0.93);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('still encloses the outermost orbit, so no planet sits off the edge of the grid', () => {
|
||||
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) {
|
||||
expect(fit(outermost).ring).toBeGreaterThan(outermost);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not overshoot either: the grid still fills most of the frame', () => {
|
||||
// A margin is not the same as framing a system from orbit. Half the frame empty would be as
|
||||
// wrong as none of it.
|
||||
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR]) {
|
||||
const { ring, frame } = fit(outermost);
|
||||
expect(ring / frame).toBeGreaterThan(0.6);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('star and framing together', () => {
|
||||
it('gives compact and wide systems a comparable apparent star size', () => {
|
||||
// Both scale with the system, so the star subtends a similar angle either way — the point
|
||||
// of deriving them from the same measurements rather than fixing them.
|
||||
const apparent = ({ innermost, outermost }: { innermost: number; outermost: number }) =>
|
||||
starMarkerRadiusAu(innermost) / systemFramingDistanceAu(outermost);
|
||||
|
||||
const compact = apparent(TRAPPIST_1);
|
||||
const midRange = apparent(GL_357);
|
||||
|
||||
expect(compact).toBeGreaterThan(0);
|
||||
expect(compact / midRange).toBeGreaterThan(0.25);
|
||||
expect(compact / midRange).toBeLessThan(4);
|
||||
});
|
||||
|
||||
it('always leaves the innermost orbit outside the star, at every scale', () => {
|
||||
for (const innermost of [0.005, 0.01, 0.05, 0.2, 1, 5, 40]) {
|
||||
expect(starMarkerRadiusAu(innermost)).toBeLessThan(innermost);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('bodyMarkerRadiusAu', () => {
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const SOLAR_SPAN_AU = 30.07;
|
||||
|
||||
it('scales in proportion to the system span', () => {
|
||||
const wide = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU);
|
||||
const compact = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU / 100);
|
||||
|
||||
expect(compact / wide).toBeCloseTo(0.01, 6);
|
||||
});
|
||||
|
||||
it('keeps a marker far smaller than the orbits it sits on, at any scale', () => {
|
||||
// A fixed 0.09 AU marker inside Gl 357's 0.204 AU system was wider than the orbits, so one
|
||||
// planet swallowed the whole view.
|
||||
for (const span of [0.06, 0.204, 1, 30.07, 800]) {
|
||||
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeLessThan(span / 5);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives compact and wide systems the same apparent marker size', () => {
|
||||
const apparent = (span: number) => bodyMarkerRadiusAu(EARTH_RADIUS_KM, span) / systemFramingDistanceAu(span);
|
||||
|
||||
expect(apparent(0.204)).toBeCloseTo(apparent(10), 6);
|
||||
});
|
||||
|
||||
it('still renders a bigger body as a bigger marker', () => {
|
||||
const jupiter = bodyMarkerRadiusAu(69911, SOLAR_SPAN_AU);
|
||||
const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU);
|
||||
|
||||
expect(jupiter).toBeGreaterThan(pluto);
|
||||
});
|
||||
|
||||
it('falls back to the smallest marker for a body with no known radius', () => {
|
||||
const unknown = bodyMarkerRadiusAu(undefined, SOLAR_SPAN_AU);
|
||||
const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU);
|
||||
|
||||
expect(unknown).toBeGreaterThan(0);
|
||||
expect(unknown).toBeLessThanOrEqual(pluto);
|
||||
});
|
||||
|
||||
it('treats a missing span as the reference scale rather than collapsing to zero', () => {
|
||||
for (const span of [0, -5, Number.NaN]) {
|
||||
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the solar system essentially as it was before scaling', () => {
|
||||
// The constants were tuned at this span, so the scale factor here is ~1.
|
||||
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU)).toBeCloseTo(0.09, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('systemGridRingsAu', () => {
|
||||
it('reaches past the outermost orbit, so no planet sits off the edge of the grid', () => {
|
||||
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR]) {
|
||||
const rings = systemGridRingsAu(outermost);
|
||||
expect(rings.length).toBeGreaterThan(0);
|
||||
expect(rings[rings.length - 1]).toBeGreaterThan(outermost);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives a legible handful of rings at every scale, four orders of magnitude apart', () => {
|
||||
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 650 }]) {
|
||||
const rings = systemGridRingsAu(outermost);
|
||||
expect(rings.length).toBeGreaterThanOrEqual(3);
|
||||
expect(rings.length).toBeLessThanOrEqual(10);
|
||||
}
|
||||
});
|
||||
|
||||
it('spaces them evenly, on a round number', () => {
|
||||
const rings = systemGridRingsAu(SOLAR.outermost);
|
||||
// The solar system reads in 5 AU steps: 5, 10, ... out past Neptune at 30.07.
|
||||
expect(rings).toEqual([5, 10, 15, 20, 25, 30, 35]);
|
||||
});
|
||||
|
||||
it('scales the step down to the system rather than defaulting to whole AU', () => {
|
||||
// TRAPPIST-1's outermost planet orbits at 0.062 AU. Whole-AU rings would put the entire
|
||||
// system inside the first one.
|
||||
const rings = systemGridRingsAu(TRAPPIST_1.outermost);
|
||||
expect(rings[0]).toBeLessThan(TRAPPIST_1.outermost / 2);
|
||||
for (const radius of rings) {
|
||||
expect(Number.isFinite(radius)).toBe(true);
|
||||
expect(radius).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the step free of floating-point drift, so labels would read cleanly', () => {
|
||||
for (const radius of systemGridRingsAu(TRAPPIST_1.outermost)) {
|
||||
// Multiplying the step out rather than accumulating it keeps these exact to 1e-12.
|
||||
expect(Math.abs(radius * 1000 - Math.round(radius * 1000))).toBeLessThan(1e-9);
|
||||
}
|
||||
});
|
||||
|
||||
it('draws no grid for a system with nothing to measure against', () => {
|
||||
for (const outermost of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
expect(systemGridRingsAu(outermost)).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('systemViewDirection', () => {
|
||||
const RAD_TO_DEG = 180 / Math.PI;
|
||||
const ECLIPTIC_FRAME = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), (OBLIQUITY_J2000_DEG * Math.PI) / 180);
|
||||
|
||||
/** Angle between the camera direction and the plane's own normal, in degrees. */
|
||||
function angleFromNormalDeg(frame: THREE.Quaternion): number {
|
||||
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(frame);
|
||||
return Math.acos(Math.abs(systemViewDirection(frame).dot(normal))) * RAD_TO_DEG;
|
||||
}
|
||||
|
||||
it('returns a unit direction', () => {
|
||||
expect(systemViewDirection(ECLIPTIC_FRAME).length()).toBeCloseTo(1, 12);
|
||||
});
|
||||
|
||||
it('holds the same three-quarter angle to the plane whatever plane that is', () => {
|
||||
// The whole point: one fixed direction in the scene's frame would be face-on for the solar
|
||||
// system and edge-on for an exoplanet system measured against the plane of the sky.
|
||||
const skyPlanes = [
|
||||
new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(0.3, -0.5, 0.81).normalize()),
|
||||
new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(-1, 0, 0)),
|
||||
new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 1, 0))
|
||||
];
|
||||
|
||||
// atan(0.6 / 0.8) — the angle the in-plane direction was chosen at, held exactly.
|
||||
const expected = Math.atan2(SYSTEM_VIEW_DIRECTION_IN_PLANE.y, SYSTEM_VIEW_DIRECTION_IN_PLANE.z) * RAD_TO_DEG;
|
||||
for (const frame of [ECLIPTIC_FRAME, ...skyPlanes]) {
|
||||
expect(angleFromNormalDeg(frame)).toBeCloseTo(expected, 9);
|
||||
}
|
||||
});
|
||||
|
||||
it('is well clear of edge-on in every case, which is what it exists to prevent', () => {
|
||||
for (const axis of [new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 1, 0), new THREE.Vector3(0.2, 0.9, -0.4).normalize()]) {
|
||||
const frame = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), axis);
|
||||
expect(angleFromNormalDeg(frame)).toBeLessThan(60);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the solar system framed exactly as the ecliptic conversion used to frame it', () => {
|
||||
// The previous behaviour was correct for the one system whose elements are ecliptic; this
|
||||
// pins that it did not move while the other systems were fixed.
|
||||
const previous = eclipticToEquatorial(SYSTEM_VIEW_DIRECTION_IN_PLANE);
|
||||
const current = systemViewDirection(ECLIPTIC_FRAME);
|
||||
const length = Math.hypot(previous.x, previous.y, previous.z);
|
||||
|
||||
expect(current.x).toBeCloseTo(previous.x / length, 12);
|
||||
expect(current.y).toBeCloseTo(previous.y / length, 12);
|
||||
expect(current.z).toBeCloseTo(previous.z / length, 12);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
|
||||
import { CartesianCoordinates } from '../../shared/astro/coordinates';
|
||||
|
||||
/**
|
||||
* How the system view sizes itself to whatever system it is showing.
|
||||
*
|
||||
* Real planetary systems span four orders of magnitude: TRAPPIST-1's outermost planet orbits
|
||||
* closer than Mercury by a factor of six, while some directly-imaged companions sit hundreds of
|
||||
* AU out. A single fixed star size and camera distance cannot serve both, and the fixed pair
|
||||
* that used to be hard-coded served only the wide end — 29% of systems had *every* orbit inside
|
||||
* the star marker, so they rendered as a lone sphere with nothing around it, and 52% were
|
||||
* framed from a distance floor far larger than the system itself.
|
||||
*
|
||||
* Both quantities are therefore derived from the system's own scale. Because the star and the
|
||||
* camera scale together, a compact system ends up looking like a wide one: same apparent star,
|
||||
* same apparent spread of orbits.
|
||||
*/
|
||||
|
||||
/** Star size when there are no orbits to scale against, and the ceiling everywhere else. */
|
||||
export const DEFAULT_STAR_MARKER_RADIUS_AU = 0.2;
|
||||
|
||||
/**
|
||||
* Star radius as a fraction of the innermost orbit. Comfortably below 1 so there is visible
|
||||
* space between the star's limb and the closest orbit, rather than the orbit grazing or
|
||||
* disappearing inside it.
|
||||
*/
|
||||
const STAR_RADIUS_TO_INNERMOST_ORBIT = 0.45;
|
||||
|
||||
/**
|
||||
* Halo extent as a multiple of the star's own radius, and the floor on that extent as a
|
||||
* fraction of the framed radius.
|
||||
*
|
||||
* The floor is what keeps a star visible. A system's star is sized against its *innermost*
|
||||
* orbit — it must never swallow its closest planet — while the camera is placed to frame the
|
||||
* *outermost* ring, and those differ by a factor of a hundred in the solar system. At the
|
||||
* distance that fits Pluto in view, a disc that stays clear of Mercury is about one pixel
|
||||
* across; there is no radius that satisfies both, because the information genuinely does not
|
||||
* fit on one screen at that zoom.
|
||||
*
|
||||
* The halo resolves it, because light is not a surface: a glow that reaches past the innermost
|
||||
* orbit does not claim the star is that large, it claims the star is bright. So the disc stays
|
||||
* honest to the orbits and the halo is floored against the frame.
|
||||
*
|
||||
* The floor is set by what it must not cover. Its visual radius is half the extent, so a floor
|
||||
* of `f` puts the halo's edge at `f / 2` of the frame radius — and the orbits it has to leave
|
||||
* legible sit at their own fraction of that same radius. In the solar system, framed to hold
|
||||
* Pluto, Venus's orbit is at 1.3% of the frame radius and Earth's at 1.8%, so a floor of 2%
|
||||
* leaves both of them outside the halo. Mercury's, at 0.7%, is inside it — and would be at any
|
||||
* halo large enough to see, since the orbit itself is only a few pixels wide there.
|
||||
*/
|
||||
const STAR_GLOW_TO_MARKER = 3.2;
|
||||
const MIN_STAR_GLOW_TO_FRAME = 0.02;
|
||||
|
||||
/**
|
||||
* Clear space left around the framed radius, as a fraction of it. The camera backs off this
|
||||
* much further than the geometry strictly needs, so the outermost ring sits inside the frame
|
||||
* with room around it rather than grazing the edge.
|
||||
*/
|
||||
const FRAME_MARGIN = 0.12;
|
||||
|
||||
/**
|
||||
* The camera the system view is framed for. The vertical field of view is what
|
||||
* `EngineService` creates its camera with; the aspect decides which screen axis is the tighter
|
||||
* one, since a perspective camera's `fov` is vertical and the horizontal extent scales with the
|
||||
* aspect. Anything landscape is bound by the vertical, anything portrait by the horizontal.
|
||||
*/
|
||||
export interface SystemViewport {
|
||||
fovDegrees: number;
|
||||
aspect: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SYSTEM_VIEWPORT: SystemViewport = { fovDegrees: 50, aspect: 1 };
|
||||
|
||||
/** Half-angle tangent along whichever screen axis is the tighter of the two. */
|
||||
function tightHalfExtent(viewport: SystemViewport): number {
|
||||
return Math.tan((viewport.fovDegrees * Math.PI) / 360) * Math.min(1, viewport.aspect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Floor on the framing distance. Only guards the degenerate case — it sits just above the
|
||||
* orbit controls' own minimum distance, so for any real system the fit above decides.
|
||||
*/
|
||||
const MIN_FRAMING_DISTANCE_AU = 0.06;
|
||||
|
||||
/**
|
||||
* Ceiling on the framing distance, so a distant companion does not push the star to a dot.
|
||||
*
|
||||
* Generous enough to frame the solar system out to Pluto in any window shape, which needs 120 AU
|
||||
* on a landscape display and 140 on a portrait one once the camera's real field of view is
|
||||
* accounted for. Only genuinely pathological systems reach it now — the handful with
|
||||
* directly-imaged companions hundreds of AU out — and those still arrive framed on their inner
|
||||
* region, with the orbit controls reaching far enough to pull back to the rest.
|
||||
*/
|
||||
const MAX_FRAMING_DISTANCE_AU = 200;
|
||||
|
||||
/** Framing for a star with no known planets, where there is nothing to fit. */
|
||||
const EMPTY_SYSTEM_FRAMING_DISTANCE_AU = 3;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the camera settles when arriving at a system, as a unit direction from the star —
|
||||
* expressed in the system's *own* reference plane, before that plane is rotated into the scene.
|
||||
*
|
||||
* A three-quarter view, about 37 degrees off the plane's normal, so a system reads as a disc
|
||||
* rather than as a line.
|
||||
*/
|
||||
export const SYSTEM_VIEW_DIRECTION_IN_PLANE: CartesianCoordinates = { x: 0, y: 0.6, z: 0.8 };
|
||||
|
||||
/**
|
||||
* That direction carried into the scene's equatorial frame by the system's own reference frame.
|
||||
*
|
||||
* The scene is equatorial so that orbits and stars share one frame, but no system's orbits lie
|
||||
* in the equatorial plane: the solar system's are measured against the ecliptic, 23.4 degrees
|
||||
* out of it, and an exoplanet system's against the plane of the sky, which depends on where its
|
||||
* host star happens to be. Left to the equatorial axes — or to any single fixed direction — some
|
||||
* systems come out edge-on.
|
||||
*
|
||||
* Rather than rotate the world into a comfortable pose, which would put the orbits back at odds
|
||||
* with the sky, the camera is placed relative to whichever plane the system was measured in. So
|
||||
* every system reads as a disc while staying exactly where it truly sits.
|
||||
*/
|
||||
export function systemViewDirection(referenceFrame: THREE.Quaternion): THREE.Vector3 {
|
||||
const { x, y, z } = SYSTEM_VIEW_DIRECTION_IN_PLANE;
|
||||
return new THREE.Vector3(x, y, z).normalize().applyQuaternion(referenceFrame);
|
||||
}
|
||||
|
||||
/**
|
||||
* Radius (AU) to draw the system's star at, given its innermost orbit.
|
||||
*
|
||||
* Never larger than {@link DEFAULT_STAR_MARKER_RADIUS_AU}, and never large enough to reach the
|
||||
* closest orbit. Falls back to that default when the system has no planets, since there is
|
||||
* then nothing for the star to crowd.
|
||||
*/
|
||||
export function starMarkerRadiusAu(innermostOrbitAu: number): number {
|
||||
if (!Number.isFinite(innermostOrbitAu) || innermostOrbitAu <= 0) {
|
||||
return DEFAULT_STAR_MARKER_RADIUS_AU;
|
||||
}
|
||||
return Math.min(DEFAULT_STAR_MARKER_RADIUS_AU, innermostOrbitAu * STAR_RADIUS_TO_INNERMOST_ORBIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Radius, in AU, that the camera can see at the star's own distance — the half-height of the
|
||||
* view frustum where the system sits, along whichever screen axis is tighter.
|
||||
*/
|
||||
export function systemFrameRadiusAu(distanceAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT): number {
|
||||
return distanceAu * tightHalfExtent(viewport);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extent (AU) of the star's glow sprite — how wide it is drawn, not its radius.
|
||||
*
|
||||
* Normally a multiple of the star's own radius, so a compact system keeps the corona it has.
|
||||
* Floored against the framed radius, so a star framed from far enough out to hold its whole
|
||||
* system still reads as a bright point rather than disappearing into it. `glowScale` lets a
|
||||
* caller dim the halo for stars drawn without a real photograph.
|
||||
*/
|
||||
export function starGlowExtentAu(markerRadiusAu: number, frameRadiusAu: number, glowScale = 1): number {
|
||||
const fromStar = markerRadiusAu * STAR_GLOW_TO_MARKER * glowScale;
|
||||
const fromFrame = Number.isFinite(frameRadiusAu) && frameRadiusAu > 0 ? frameRadiusAu * MIN_STAR_GLOW_TO_FRAME : 0;
|
||||
return Math.max(fromStar, fromFrame);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance (AU) to settle the camera at so that `framedRadiusAu` fits in view with a margin
|
||||
* around it.
|
||||
*
|
||||
* Derived from the camera's actual field of view rather than from a multiple of the outermost
|
||||
* orbit. A plain multiple cannot be right: what has to fit is a *radius* on screen, and how much
|
||||
* radius a given distance buys depends entirely on the lens. The multiple that used to be here
|
||||
* was tuned by eye against a 55-degree field, and the engine's camera is 50 — which left the
|
||||
* grid overflowing the frame in 368 of the 371 systems the datasets contain.
|
||||
*
|
||||
* Callers pass the outermost thing actually drawn, which is the reference grid's outer ring
|
||||
* rather than the outermost orbit — the ring is always the wider of the two, by construction.
|
||||
*/
|
||||
export function systemFramingDistanceAu(framedRadiusAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT): number {
|
||||
if (!Number.isFinite(framedRadiusAu) || framedRadiusAu <= 0) {
|
||||
return EMPTY_SYSTEM_FRAMING_DISTANCE_AU;
|
||||
}
|
||||
const required = (framedRadiusAu * (1 + FRAME_MARGIN)) / tightHalfExtent(viewport);
|
||||
return clamp(required, MIN_FRAMING_DISTANCE_AU, MAX_FRAMING_DISTANCE_AU);
|
||||
}
|
||||
|
||||
/** Roughly how many rings the system grid aims for, and how far past the outermost orbit it runs. */
|
||||
const TARGET_GRID_RING_COUNT = 8;
|
||||
const GRID_EXTENT_TO_OUTERMOST_ORBIT = 1.15;
|
||||
/** Ring spacings are always one of these times a power of ten, so the numbers stay readable. */
|
||||
const RING_STEP_MANTISSAS = [1, 2, 5, 10];
|
||||
|
||||
/**
|
||||
* Ring radii (AU) for the system view's reference grid, given the system's outermost orbit.
|
||||
*
|
||||
* Snapped to a 1-2-5 ladder rather than evenly dividing the system, because the point of the
|
||||
* grid is to put a number on a distance: rings at 5, 10, 15 AU can be read off at a glance, and
|
||||
* rings at 4.34, 8.68, 13.02 AU cannot. That holds across the four orders of magnitude real
|
||||
* systems span — the solar system gets 5 AU rings, TRAPPIST-1 gets 0.01 AU ones.
|
||||
*
|
||||
* Empty for a system with no orbits to scale against; there is no distance to mark out.
|
||||
*/
|
||||
export function systemGridRingsAu(outermostOrbitAu: number): number[] {
|
||||
if (!Number.isFinite(outermostOrbitAu) || outermostOrbitAu <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const extent = outermostOrbitAu * GRID_EXTENT_TO_OUTERMOST_ORBIT;
|
||||
const target = extent / TARGET_GRID_RING_COUNT;
|
||||
const magnitude = Math.pow(10, Math.floor(Math.log10(target)));
|
||||
const step = magnitude * (RING_STEP_MANTISSAS.find((mantissa) => magnitude * mantissa >= target) ?? 10);
|
||||
|
||||
// Rounded up, not truncated: the last ring has to enclose the outermost orbit rather than fall
|
||||
// just inside it, or the outermost planet spends its year outside the grid meant to measure it.
|
||||
const count = Math.ceil(extent / step);
|
||||
const rings: number[] = [];
|
||||
// Multiplied rather than accumulated, so a step of 0.01 does not drift into 0.060000000000000005.
|
||||
for (let index = 1; index <= count; index++) {
|
||||
rings.push(index * step);
|
||||
}
|
||||
return rings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Span of the solar system, in AU, used as the reference every other system's marker sizes are
|
||||
* scaled against. The marker constants below were tuned by eye at this scale.
|
||||
*/
|
||||
const REFERENCE_SYSTEM_SPAN_AU = 30;
|
||||
|
||||
/** Exaggerated (non-physical) marker sizes at the reference scale, so planets stay visible. */
|
||||
const MIN_MARKER_RADIUS_AU = 0.012;
|
||||
const MAX_MARKER_RADIUS_AU = 0.09;
|
||||
/** Physical radius (km) that maps to one AU of marker radius before clamping. */
|
||||
const MARKER_RADIUS_KM_PER_AU = 18000;
|
||||
|
||||
/**
|
||||
* Radius (AU) to draw a planet, moon or exoplanet marker at, scaled to the system it sits in.
|
||||
*
|
||||
* Marker sizes are deliberately exaggerated — a true-scale Earth would be invisible next to its
|
||||
* own orbit — but the exaggeration has to be relative to the system, not absolute. Fixed AU
|
||||
* sizes tuned against the solar system's 30 AU span become grotesque in a system a hundredth
|
||||
* that size: a marker of 0.09 AU inside a 0.2 AU system is wider than the orbits it sits on, so
|
||||
* a single planet swallows the entire view.
|
||||
*
|
||||
* Scaling by the span keeps every system looking like the solar system does: orbits legible,
|
||||
* planets as small dots on them.
|
||||
*/
|
||||
export function bodyMarkerRadiusAu(radiusKm: number | undefined, systemSpanAu: number): number {
|
||||
const span = Number.isFinite(systemSpanAu) && systemSpanAu > 0 ? systemSpanAu : REFERENCE_SYSTEM_SPAN_AU;
|
||||
const atReferenceScale = radiusKm ? clamp(radiusKm / MARKER_RADIUS_KM_PER_AU, MIN_MARKER_RADIUS_AU, MAX_MARKER_RADIUS_AU) : MIN_MARKER_RADIUS_AU;
|
||||
|
||||
return atReferenceScale * (span / REFERENCE_SYSTEM_SPAN_AU);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_EPOCH_JD } from '../../shared/astro/constants';
|
||||
import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
||||
import { BodyRecord } from '../../shared/models/body.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
||||
|
||||
/** TRAPPIST-1 b: a real short-period planet around a 0.09 solar-mass red dwarf. */
|
||||
const TRAPPIST_1B_SEMI_MAJOR_AXIS_AU = 0.01154;
|
||||
const TRAPPIST_1B_PERIOD_DAYS = 1.51088;
|
||||
|
||||
function exoplanet(overrides: Partial<ExoplanetRecord> = {}): ExoplanetRecord {
|
||||
return {
|
||||
id: 'TRAPPIST-1 b',
|
||||
hostStarId: 1,
|
||||
hostStarName: 'TRAPPIST-1',
|
||||
name: 'TRAPPIST-1 b',
|
||||
orbit: { semiMajorAxisAu: TRAPPIST_1B_SEMI_MAJOR_AXIS_AU, eccentricity: 0 },
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/** Marker position for the system's single exoplanet at a given Julian date. */
|
||||
function positionAt(renderer: SystemOrbitsRenderer, epochJd: number): THREE.Vector3 {
|
||||
renderer.update(epochJd);
|
||||
return renderer.members[0].marker.position.clone();
|
||||
}
|
||||
|
||||
describe('SystemOrbitsRenderer exoplanet propagation', () => {
|
||||
it('completes exactly one orbit over the measured period', () => {
|
||||
// The end-to-end check that the period actually reaches the propagator: after one full
|
||||
// published period the planet must be back where it started.
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS })]);
|
||||
|
||||
const start = positionAt(renderer, DEFAULT_EPOCH_JD);
|
||||
const afterOnePeriod = positionAt(renderer, DEFAULT_EPOCH_JD + TRAPPIST_1B_PERIOD_DAYS);
|
||||
const afterHalfPeriod = positionAt(renderer, DEFAULT_EPOCH_JD + TRAPPIST_1B_PERIOD_DAYS / 2);
|
||||
|
||||
expect(afterOnePeriod.distanceTo(start)).toBeLessThan(1e-6);
|
||||
// Half an orbit of a circle is the far side, a full diameter away.
|
||||
expect(afterHalfPeriod.distanceTo(start)).toBeCloseTo(2 * TRAPPIST_1B_SEMI_MAJOR_AXIS_AU, 6);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('moves a red dwarf planet more slowly than the old solar-mass assumption did', () => {
|
||||
// Assuming a solar-mass host made TRAPPIST-1's planets orbit about 3.3x too fast, so the
|
||||
// corrected planet must have travelled less far after the same elapsed time.
|
||||
const corrected = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS })]);
|
||||
const assumingSolar = new SystemOrbitsRenderer([], [exoplanet()]);
|
||||
|
||||
const elapsed = TRAPPIST_1B_PERIOD_DAYS / 8;
|
||||
const correctedTravel = positionAt(corrected, DEFAULT_EPOCH_JD).distanceTo(positionAt(corrected, DEFAULT_EPOCH_JD + elapsed));
|
||||
const solarTravel = positionAt(assumingSolar, DEFAULT_EPOCH_JD).distanceTo(
|
||||
positionAt(assumingSolar, DEFAULT_EPOCH_JD + elapsed)
|
||||
);
|
||||
|
||||
expect(correctedTravel).toBeLessThan(solarTravel);
|
||||
corrected.dispose();
|
||||
assumingSolar.dispose();
|
||||
});
|
||||
|
||||
it('uses the host star mass when no period is published', () => {
|
||||
const fromMass = new SystemOrbitsRenderer([], [exoplanet({ hostStarMassSolar: 0.0898 })]);
|
||||
const fromPeriod = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS })]);
|
||||
|
||||
const elapsed = 0.3;
|
||||
const massTravel = positionAt(fromMass, DEFAULT_EPOCH_JD).distanceTo(positionAt(fromMass, DEFAULT_EPOCH_JD + elapsed));
|
||||
const periodTravel = positionAt(fromPeriod, DEFAULT_EPOCH_JD).distanceTo(positionAt(fromPeriod, DEFAULT_EPOCH_JD + elapsed));
|
||||
|
||||
// The published mass and the period-derived mass agree, so the two must nearly coincide.
|
||||
expect(massTravel).toBeCloseTo(periodTravel, 4);
|
||||
fromMass.dispose();
|
||||
fromPeriod.dispose();
|
||||
});
|
||||
|
||||
it('still renders an exoplanet that has neither a period nor a host mass', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet()]);
|
||||
|
||||
expect(renderer.members).toHaveLength(1);
|
||||
expect(positionAt(renderer, DEFAULT_EPOCH_JD).length()).toBeCloseTo(TRAPPIST_1B_SEMI_MAJOR_AXIS_AU, 6);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('skips an exoplanet with no semi-major axis rather than crashing', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet({ orbit: { eccentricity: 0 } })]);
|
||||
|
||||
expect(renderer.members).toHaveLength(0);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
describe('orbits with no published eccentricity', () => {
|
||||
// The archive publishes a semi-major axis far more often than an eccentricity. Requiring
|
||||
// both dropped 1509 otherwise drawable planets.
|
||||
it('draws a planet that has an axis but no eccentricity', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet({ orbit: { semiMajorAxisAu: 0.4 } })]);
|
||||
|
||||
expect(renderer.members).toHaveLength(1);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('places it on a circle of the right radius', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet({ orbit: { semiMajorAxisAu: 0.4 } })]);
|
||||
|
||||
for (const offset of [0, 5, 20, 60]) {
|
||||
expect(positionAt(renderer, DEFAULT_EPOCH_JD + offset).length()).toBeCloseTo(0.4, 6);
|
||||
}
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('still honours the measured period', () => {
|
||||
const renderer = new SystemOrbitsRenderer(
|
||||
[],
|
||||
[exoplanet({ orbit: { semiMajorAxisAu: TRAPPIST_1B_SEMI_MAJOR_AXIS_AU }, periodDays: TRAPPIST_1B_PERIOD_DAYS })]
|
||||
);
|
||||
|
||||
const start = positionAt(renderer, DEFAULT_EPOCH_JD);
|
||||
const afterOnePeriod = positionAt(renderer, DEFAULT_EPOCH_JD + TRAPPIST_1B_PERIOD_DAYS);
|
||||
expect(afterOnePeriod.distanceTo(start)).toBeLessThan(1e-6);
|
||||
renderer.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it('skips an escape trajectory rather than emitting NaN positions', () => {
|
||||
// e >= 1 is not an ellipse; propagating it anyway yields NaN, which poisons the geometry's
|
||||
// bounding sphere and disables culling for the whole object.
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet({ orbit: { semiMajorAxisAu: 1, eccentricity: 1.4 } })]);
|
||||
|
||||
expect(renderer.members).toHaveLength(0);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('skips a non-positive semi-major axis', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet({ orbit: { semiMajorAxisAu: 0, eccentricity: 0.1 } })]);
|
||||
|
||||
expect(renderer.members).toHaveLength(0);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('keeps every propagated position finite', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS, orbit: { semiMajorAxisAu: TRAPPIST_1B_SEMI_MAJOR_AXIS_AU, eccentricity: 0.62 } })]);
|
||||
|
||||
for (const offset of [0, 0.1, 1, 10, 1000]) {
|
||||
const { x, y, z } = positionAt(renderer, DEFAULT_EPOCH_JD + offset);
|
||||
expect([x, y, z].every(Number.isFinite)).toBe(true);
|
||||
}
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
describe('reference frame', () => {
|
||||
/** Earth: inclination 0 by definition — its orbit *is* the ecliptic plane. */
|
||||
const EARTH: BodyRecord = {
|
||||
id: 'earth',
|
||||
systemStarId: 0,
|
||||
name: 'Earth',
|
||||
kind: 'planet',
|
||||
radiusKm: 6371,
|
||||
orbit: {
|
||||
semiMajorAxisAu: 1,
|
||||
eccentricity: 0.0167,
|
||||
inclinationDeg: 0,
|
||||
longitudeOfAscendingNodeDeg: 0,
|
||||
argumentOfPeriapsisDeg: 0,
|
||||
meanAnomalyAtEpochDeg: 0,
|
||||
epochJd: DEFAULT_EPOCH_JD
|
||||
}
|
||||
};
|
||||
|
||||
it('places an ecliptic orbit in the ecliptic plane of the equatorial scene', () => {
|
||||
// Horizons reports elements against the ecliptic; the scene is equatorial, to match the
|
||||
// star catalogue. So Earth's orbit must come out tilted, lying perpendicular to the
|
||||
// *ecliptic* pole rather than to the scene's own vertical.
|
||||
const renderer = new SystemOrbitsRenderer([EARTH], []);
|
||||
const eclipticPole = eclipticToEquatorial({ x: 0, y: 0, z: 1 });
|
||||
|
||||
for (const offset of [0, 40, 91, 200, 300]) {
|
||||
renderer.update(DEFAULT_EPOCH_JD + offset);
|
||||
const p = renderer.members[0].marker.position;
|
||||
const outOfPlane = p.x * eclipticPole.x + p.y * eclipticPole.y + p.z * eclipticPole.z;
|
||||
expect(Math.abs(outOfPlane)).toBeLessThan(1e-9);
|
||||
}
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('tilts that orbit away from the celestial equator by the obliquity', () => {
|
||||
// The discriminating check: before the frames were reconciled, the orbit sat flat in the
|
||||
// scene and this angle was zero.
|
||||
const renderer = new SystemOrbitsRenderer([EARTH], []);
|
||||
renderer.update(DEFAULT_EPOCH_JD + 91); // a quarter orbit on, well away from the equinox
|
||||
|
||||
const p = renderer.members[0].marker.position;
|
||||
const latitudeDeg = (Math.asin(p.z / p.length()) * 180) / Math.PI;
|
||||
|
||||
expect(Math.abs(latitudeDeg)).toBeGreaterThan(1);
|
||||
expect(Math.abs(latitudeDeg)).toBeLessThanOrEqual(OBLIQUITY_J2000_DEG + 1e-6);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('keeps the vernal equinox direction shared between the two frames', () => {
|
||||
// A body at ecliptic longitude 0 sits on the +X axis in both frames, so it must not move.
|
||||
const atEquinox: BodyRecord = { ...EARTH, orbit: { ...EARTH.orbit, eccentricity: 0 } };
|
||||
const renderer = new SystemOrbitsRenderer([atEquinox], []);
|
||||
renderer.update(DEFAULT_EPOCH_JD);
|
||||
|
||||
const p = renderer.members[0].marker.position;
|
||||
expect(p.x).toBeCloseTo(1, 6);
|
||||
expect(p.y).toBeCloseTo(0, 9);
|
||||
expect(p.z).toBeCloseTo(0, 9);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('reads the solar system against the ecliptic and everything else against the sky plane', () => {
|
||||
const solar = new SystemOrbitsRenderer([EARTH], []);
|
||||
const eclipticPole = eclipticToEquatorial({ x: 0, y: 0, z: 1 });
|
||||
const solarNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(solar.referenceFrame);
|
||||
expect(solarNormal.dot(new THREE.Vector3(eclipticPole.x, eclipticPole.y, eclipticPole.z))).toBeCloseTo(1, 9);
|
||||
solar.dispose();
|
||||
|
||||
const lineOfSight = { x: 0.3, y: -0.5, z: 0.81 };
|
||||
const exo = new SystemOrbitsRenderer([], [exoplanet()], lineOfSight);
|
||||
const exoNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(exo.referenceFrame);
|
||||
const expected = new THREE.Vector3(lineOfSight.x, lineOfSight.y, lineOfSight.z).normalize();
|
||||
expect(exoNormal.dot(expected)).toBeCloseTo(1, 9);
|
||||
exo.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reference grid', () => {
|
||||
/** A body far enough out to give the grid something to measure. */
|
||||
const JUPITER: BodyRecord = {
|
||||
id: 'jupiter',
|
||||
systemStarId: 0,
|
||||
name: 'Jupiter',
|
||||
kind: 'planet',
|
||||
radiusKm: 69911,
|
||||
orbit: { semiMajorAxisAu: 5.2, eccentricity: 0.048, inclinationDeg: 1.3, longitudeOfAscendingNodeDeg: 100, argumentOfPeriapsisDeg: 275, meanAnomalyAtEpochDeg: 20, epochJd: DEFAULT_EPOCH_JD }
|
||||
};
|
||||
|
||||
/** The grid and the tethers are the only line objects the renderer adds outside a pivot. */
|
||||
function planeObjects(renderer: SystemOrbitsRenderer): THREE.LineSegments[] {
|
||||
return renderer.object.children.filter((child): child is THREE.LineSegments => child instanceof THREE.LineSegments);
|
||||
}
|
||||
|
||||
it('lays a grid and tethers in the system plane', () => {
|
||||
const renderer = new SystemOrbitsRenderer([JUPITER], []);
|
||||
expect(planeObjects(renderer)).toHaveLength(2);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('drops a tether from every top-level body onto that plane, and follows them', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS })]);
|
||||
renderer.update(DEFAULT_EPOCH_JD);
|
||||
|
||||
// The tether field is the one with an explicit draw range; the grid leaves it at Infinity.
|
||||
const tethers = planeObjects(renderer).find((object) => Number.isFinite(object.geometry.drawRange.count))!;
|
||||
const readTop = (): THREE.Vector3 => {
|
||||
const position = tethers.geometry.getAttribute('position');
|
||||
return new THREE.Vector3(position.getX(0), position.getY(0), position.getZ(0));
|
||||
};
|
||||
|
||||
// The tether's top is the marker, wherever the marker currently is.
|
||||
expect(readTop().distanceTo(renderer.members[0].marker.position)).toBeCloseTo(0, 9);
|
||||
const before = readTop();
|
||||
|
||||
renderer.update(DEFAULT_EPOCH_JD + TRAPPIST_1B_PERIOD_DAYS / 2);
|
||||
expect(readTop().distanceTo(renderer.members[0].marker.position)).toBeCloseTo(0, 9);
|
||||
expect(readTop().distanceTo(before)).toBeGreaterThan(0);
|
||||
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('draws no grid for a star with no known planets', () => {
|
||||
// Nothing to measure, and a bare ring around a lone star would imply a scale it does not
|
||||
// have.
|
||||
const renderer = new SystemOrbitsRenderer([], []);
|
||||
expect(planeObjects(renderer)).toHaveLength(0);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('detaches the grid on dispose along with everything else', () => {
|
||||
const renderer = new SystemOrbitsRenderer([JUPITER], []);
|
||||
const [grid] = planeObjects(renderer);
|
||||
renderer.dispose();
|
||||
expect(grid.parent).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('exoplanet inclination is measured from the plane of the sky', () => {
|
||||
// A host somewhere off all three axes, so nothing can pass by coincidence.
|
||||
const LINE_OF_SIGHT = new THREE.Vector3(0.37, -0.62, 0.69).normalize();
|
||||
|
||||
function circular(inclinationDeg: number): ExoplanetRecord {
|
||||
return exoplanet({ orbit: { semiMajorAxisAu: 0.5, eccentricity: 0, inclinationDeg } });
|
||||
}
|
||||
|
||||
/** Normal of the plane the rendered orbit actually lies in. */
|
||||
function orbitNormal(renderer: SystemOrbitsRenderer): THREE.Vector3 {
|
||||
const a = positionAt(renderer, DEFAULT_EPOCH_JD);
|
||||
const b = positionAt(renderer, DEFAULT_EPOCH_JD + 20);
|
||||
return new THREE.Vector3().crossVectors(a, b).normalize();
|
||||
}
|
||||
|
||||
it('tilts the orbit by the published inclination away from the line of sight', () => {
|
||||
// The definition: inclination is the angle between the orbital axis and our line of
|
||||
// sight to the star. Reading it as an ecliptic inclination instead tips the orbit against
|
||||
// a plane it was never measured against.
|
||||
for (const inclinationDeg of [0, 30, 60, 88.9, 90]) {
|
||||
const renderer = new SystemOrbitsRenderer([], [circular(inclinationDeg)], LINE_OF_SIGHT);
|
||||
const angleDeg = (Math.acos(Math.abs(orbitNormal(renderer).dot(LINE_OF_SIGHT))) * 180) / Math.PI;
|
||||
|
||||
expect(angleDeg).toBeCloseTo(inclinationDeg <= 90 ? inclinationDeg : 180 - inclinationDeg, 4);
|
||||
renderer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('makes an edge-on planet actually transit its star as seen from Earth', () => {
|
||||
// 90 degrees means edge-on to us, which is why transiting planets cluster there. So some
|
||||
// point on the orbit must lie along the line of sight — in front of or behind the star.
|
||||
const renderer = new SystemOrbitsRenderer([], [circular(90)], LINE_OF_SIGHT);
|
||||
|
||||
let closestToLineOfSight = 0;
|
||||
for (let day = 0; day < 120; day++) {
|
||||
const p = positionAt(renderer, DEFAULT_EPOCH_JD + day).normalize();
|
||||
closestToLineOfSight = Math.max(closestToLineOfSight, Math.abs(p.dot(LINE_OF_SIGHT)));
|
||||
}
|
||||
|
||||
expect(closestToLineOfSight).toBeGreaterThan(0.99);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('keeps a face-on planet in the plane of the sky, never transiting', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [circular(0)], LINE_OF_SIGHT);
|
||||
|
||||
for (let day = 0; day < 120; day += 7) {
|
||||
const p = positionAt(renderer, DEFAULT_EPOCH_JD + day).normalize();
|
||||
expect(Math.abs(p.dot(LINE_OF_SIGHT))).toBeLessThan(1e-9);
|
||||
}
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('places identical elements differently for hosts in different directions', () => {
|
||||
// Each system is oriented against its own line of sight, so the same elements around two
|
||||
// stars in different parts of the sky do not land in the same place.
|
||||
//
|
||||
// Note this checks position, not the plane's normal. With no published node angle the
|
||||
// rotation about the line of sight is arbitrary, so two planes can come out near-parallel
|
||||
// by coincidence while each still sits at its correct inclination to its own host — which
|
||||
// is the property the test above pins.
|
||||
const here = new SystemOrbitsRenderer([], [circular(88.9)], new THREE.Vector3(1, 0, 0));
|
||||
const there = new SystemOrbitsRenderer([], [circular(88.9)], new THREE.Vector3(0, 0, 1));
|
||||
|
||||
expect(positionAt(here, DEFAULT_EPOCH_JD).distanceTo(positionAt(there, DEFAULT_EPOCH_JD))).toBeGreaterThan(0.1);
|
||||
here.dispose();
|
||||
there.dispose();
|
||||
});
|
||||
|
||||
it('falls back to the ecliptic frame when the host direction is unknown', () => {
|
||||
const withoutHost = new SystemOrbitsRenderer([], [circular(0)]);
|
||||
const eclipticPole = eclipticToEquatorial({ x: 0, y: 0, z: 1 });
|
||||
|
||||
expect(Math.abs(orbitNormal(withoutHost).dot(new THREE.Vector3(eclipticPole.x, eclipticPole.y, eclipticPole.z)))).toBeCloseTo(1, 9);
|
||||
withoutHost.dispose();
|
||||
});
|
||||
|
||||
it('ignores a zero-length host direction rather than producing NaN', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [circular(45)], new THREE.Vector3(0, 0, 0));
|
||||
const p = positionAt(renderer, DEFAULT_EPOCH_JD);
|
||||
|
||||
expect([p.x, p.y, p.z].every(Number.isFinite)).toBe(true);
|
||||
renderer.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,14 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
|
||||
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
||||
import { gmForParent } from '../../shared/astro/constants';
|
||||
import { orbitEllipsePoints, propagateOrbit, resolveOrbitalElements } from '../../shared/astro/kepler';
|
||||
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
|
||||
import { MARKER_TEXTURE_HEIGHT, MARKER_TEXTURE_WIDTH, planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
||||
import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
|
||||
import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
||||
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
|
||||
import { bodyMarkerRadiusAu, systemGridRingsAu } from './system-framing';
|
||||
import { PolarGridPlane, TetherField } from './grid-plane';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
|
||||
export type SystemMemberKind = 'planet' | 'moon' | 'dwarf' | 'exoplanet';
|
||||
@@ -27,15 +33,46 @@ const ORBIT_LINE_OPACITY_BY_KIND: Record<SystemMemberKind, number> = {
|
||||
};
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const MIN_MARKER_RADIUS_AU = 0.012;
|
||||
const MAX_MARKER_RADIUS_AU = 0.09;
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
|
||||
/** Exaggerated (non-physical) marker radius so planets stay visible at AU scale. */
|
||||
function markerRadiusAu(radiusKm: number | undefined): number {
|
||||
if (!radiusKm) {
|
||||
return MIN_MARKER_RADIUS_AU;
|
||||
/** Spokes on the system's reference grid, and how loudly it is drawn against the orbits. */
|
||||
const SYSTEM_GRID_SPOKES = 12;
|
||||
const SYSTEM_GRID_OPACITY = 0.28;
|
||||
const SYSTEM_TETHER_OPACITY = 0.3;
|
||||
|
||||
/**
|
||||
* Rotation carrying the **ecliptic** frame into the scene's equatorial one — a turn of the
|
||||
* obliquity about the shared vernal-equinox axis. Solar-system elements come from Horizons
|
||||
* against the ecliptic, so this is their frame.
|
||||
*/
|
||||
const ECLIPTIC_FRAME = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), OBLIQUITY_J2000_DEG * DEG_TO_RAD);
|
||||
|
||||
/**
|
||||
* Rotation carrying the frame an **exoplanet's** elements are measured in into the scene.
|
||||
*
|
||||
* The Exoplanet Archive measures inclination from the *plane of the sky* — the plane
|
||||
* perpendicular to our line of sight to the host star — not from the ecliptic. 90 degrees means
|
||||
* edge-on as seen from Earth, which is why transiting planets cluster there: 1643 of the 2061
|
||||
* published inclinations are within 5 degrees of 90. Treating that as an ecliptic inclination
|
||||
* tips every transiting system on its side against a plane it was never measured against.
|
||||
*
|
||||
* Carrying the elements' +Z onto the line of sight fixes it: an inclination of `i` then means
|
||||
* the orbit's normal sits `i` from our line of sight, which is exactly the definition. The
|
||||
* rotation about that axis is the node's position angle on the sky, which the archive does not
|
||||
* publish, so the shortest arc from +Z is used — deterministic, and no less arbitrary than any
|
||||
* other choice given no data.
|
||||
*
|
||||
* Falls back to the ecliptic frame when there is no direction to work with.
|
||||
*/
|
||||
function skyPlaneFrame(lineOfSight: CartesianCoordinates | undefined): THREE.Quaternion {
|
||||
if (!lineOfSight) {
|
||||
return ECLIPTIC_FRAME.clone();
|
||||
}
|
||||
return THREE.MathUtils.clamp(radiusKm / 18000, MIN_MARKER_RADIUS_AU, MAX_MARKER_RADIUS_AU);
|
||||
const direction = new THREE.Vector3(lineOfSight.x, lineOfSight.y, lineOfSight.z);
|
||||
if (direction.lengthSq() === 0) {
|
||||
return ECLIPTIC_FRAME.clone();
|
||||
}
|
||||
return new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), direction.normalize());
|
||||
}
|
||||
|
||||
function colorForKind(kind: SystemMemberKind): THREE.Color {
|
||||
@@ -51,13 +88,17 @@ function colorForKind(kind: SystemMemberKind): THREE.Color {
|
||||
}
|
||||
}
|
||||
|
||||
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind): THREE.Line {
|
||||
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame: THREE.Quaternion): THREE.Line {
|
||||
const points = orbitEllipsePoints(elements);
|
||||
const positions = new Float32Array(points.length * 3);
|
||||
const scratch = new THREE.Vector3();
|
||||
points.forEach((point, index) => {
|
||||
positions[index * 3] = point.x;
|
||||
positions[index * 3 + 1] = point.z; // AU "up" (ecliptic normal) maps to scene Y.
|
||||
positions[index * 3 + 2] = point.y;
|
||||
// Elements are measured against their source's own reference plane; `frame` rotates that
|
||||
// plane into the scene's equatorial one.
|
||||
const { x, y, z } = scratch.set(point.x, point.y, point.z).applyQuaternion(frame);
|
||||
positions[index * 3] = x;
|
||||
positions[index * 3 + 1] = y;
|
||||
positions[index * 3 + 2] = z;
|
||||
});
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
@@ -72,9 +113,19 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind): THRE
|
||||
return new THREE.Line(geometry, material);
|
||||
}
|
||||
|
||||
function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined): THREE.Mesh {
|
||||
const geometry = new THREE.SphereGeometry(markerRadiusAu(radiusKm), 16, 12);
|
||||
const material = new THREE.MeshBasicMaterial({ color: colorForKind(kind) });
|
||||
/**
|
||||
* A marker sphere, surfaced with the body's own derived appearance rather than a flat category
|
||||
* colour — so a system reads as a set of distinct worlds at a glance, and the colour of each is
|
||||
* a consequence of its measurements rather than of which list it came from.
|
||||
*
|
||||
* The texture is tiny (see `MARKER_TEXTURE_WIDTH`): a marker is a few pixels across, so what
|
||||
* survives is essentially its average colour, and generating it costs well under a millisecond.
|
||||
*/
|
||||
function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined, systemSpanAu: number, appearance: PlanetAppearance | undefined): THREE.Mesh {
|
||||
const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm, systemSpanAu), 16, 12);
|
||||
const material = appearance
|
||||
? new THREE.MeshBasicMaterial({ map: planetTexture(appearance, { width: MARKER_TEXTURE_WIDTH, height: MARKER_TEXTURE_HEIGHT }) })
|
||||
: new THREE.MeshBasicMaterial({ color: colorForKind(kind) });
|
||||
return new THREE.Mesh(geometry, material);
|
||||
}
|
||||
|
||||
@@ -84,6 +135,8 @@ interface TrackedTopLevelBody {
|
||||
elements: OrbitalElements;
|
||||
gmAu3PerDay2: number;
|
||||
marker: THREE.Mesh;
|
||||
/** Rotation from this body's own element frame into the scene's equatorial one. */
|
||||
frame: THREE.Quaternion;
|
||||
/** AU position last computed for this body; moons read their parent's here. */
|
||||
position: THREE.Vector3;
|
||||
}
|
||||
@@ -93,6 +146,7 @@ interface TrackedMoon {
|
||||
elements: OrbitalElements;
|
||||
gmAu3PerDay2: number;
|
||||
marker: THREE.Mesh;
|
||||
frame: THREE.Quaternion;
|
||||
pivot: THREE.Group;
|
||||
parentId: string;
|
||||
}
|
||||
@@ -108,15 +162,54 @@ export class SystemOrbitsRenderer {
|
||||
readonly members: readonly SystemMember[];
|
||||
/** Largest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
|
||||
readonly maxTopLevelSemiMajorAxisAu: number;
|
||||
/** Smallest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
|
||||
readonly minTopLevelSemiMajorAxisAu: number;
|
||||
/**
|
||||
* The plane this system is read against, as a rotation from XY into the scene's equatorial
|
||||
* frame: the ecliptic for the solar system, the plane of the sky for everything else.
|
||||
*/
|
||||
readonly referenceFrame: THREE.Quaternion;
|
||||
/**
|
||||
* Outer radius (AU) of the reference grid, or 0 where there is none. This — not the outermost
|
||||
* orbit — is the widest thing the system draws, so it is what the camera has to frame.
|
||||
*/
|
||||
readonly gridOuterRadiusAu: number;
|
||||
|
||||
private readonly topLevelBodies: TrackedTopLevelBody[] = [];
|
||||
private readonly moons: TrackedMoon[] = [];
|
||||
private readonly disposables: Array<{ geometry: THREE.BufferGeometry; material: THREE.Material }> = [];
|
||||
private readonly grid?: PolarGridPlane;
|
||||
private readonly tethers?: TetherField;
|
||||
/**
|
||||
* Aliases of the tracked bodies' own position vectors, which `update` writes in place — so
|
||||
* following them each tick costs no allocation at all.
|
||||
*/
|
||||
private tetherPoints: readonly THREE.Vector3[] = [];
|
||||
|
||||
constructor(bodies: readonly BodyRecord[], exoplanets: readonly ExoplanetRecord[]) {
|
||||
constructor(
|
||||
bodies: readonly BodyRecord[],
|
||||
exoplanets: readonly ExoplanetRecord[],
|
||||
/** Direction from the Sun to this system's host star, equatorial — the exoplanet line of sight. */
|
||||
hostStarDirection?: CartesianCoordinates,
|
||||
/**
|
||||
* The host star's luminosity in solar units, which is what sets how hot each body in the
|
||||
* system is and therefore what it looks like. Omitted for a host that is not in the star
|
||||
* catalogue, leaving its bodies classified on size and density alone.
|
||||
*/
|
||||
hostLuminositySolar?: number | null
|
||||
) {
|
||||
const members: SystemMember[] = [];
|
||||
const topLevelBodiesById = new Map<string, BodyRecord>();
|
||||
|
||||
// Measured before anything is built, because marker sizes are scaled against the span and
|
||||
// the markers are created as the bodies are added.
|
||||
const topLevelAxes = [
|
||||
...bodies.filter((body) => !body.parentBodyId).map((body) => body.orbit.semiMajorAxisAu),
|
||||
...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map((exoplanet) => exoplanet.orbit.semiMajorAxisAu!)
|
||||
].filter((axis) => Number.isFinite(axis) && axis > 0);
|
||||
this.maxTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.max(...topLevelAxes) : 0;
|
||||
this.minTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.min(...topLevelAxes) : 0;
|
||||
|
||||
for (const body of bodies) {
|
||||
if (!body.parentBodyId) {
|
||||
topLevelBodiesById.set(body.id, body);
|
||||
@@ -129,7 +222,7 @@ export class SystemOrbitsRenderer {
|
||||
}
|
||||
// A body reaches here only when it has no parentBodyId, so `kind` is 'planet' or 'dwarf'.
|
||||
const kind: SystemMemberKind = body.kind;
|
||||
const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm);
|
||||
const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar));
|
||||
members.push({ id: body.id, kind, marker: tracked.marker });
|
||||
}
|
||||
|
||||
@@ -142,37 +235,72 @@ export class SystemOrbitsRenderer {
|
||||
if (!parentTracked) {
|
||||
continue; // orphaned moon reference; skip rather than crash.
|
||||
}
|
||||
const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked);
|
||||
const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar));
|
||||
members.push({ id: body.id, kind: 'moon', marker: moon.marker });
|
||||
}
|
||||
|
||||
// Every exoplanet in a system shares the same line of sight, so the frame is built once.
|
||||
const exoplanetFrame = skyPlaneFrame(hostStarDirection);
|
||||
|
||||
for (const exoplanet of exoplanets) {
|
||||
if (!exoplanet.orbit.semiMajorAxisAu || exoplanet.orbit.eccentricity === undefined) {
|
||||
continue; // not enough data to place on an orbit.
|
||||
// Only a semi-major axis is genuinely required; resolveOrbitalElements defaults the rest,
|
||||
// eccentricity included. Demanding a published eccentricity as well used to drop 1509
|
||||
// otherwise drawable planets, so a user could open one's detail page, jump to its system,
|
||||
// and find it missing from the very system it belongs to.
|
||||
if (!isPropagatableOrbit(exoplanet.orbit)) {
|
||||
continue;
|
||||
}
|
||||
const elements = resolveOrbitalElements({
|
||||
semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu,
|
||||
eccentricity: exoplanet.orbit.eccentricity,
|
||||
inclinationDeg: exoplanet.orbit.inclinationDeg,
|
||||
longitudeOfAscendingNodeDeg: exoplanet.orbit.longitudeOfAscendingNodeDeg,
|
||||
argumentOfPeriapsisDeg: exoplanet.orbit.argumentOfPeriapsisDeg,
|
||||
meanAnomalyAtEpochDeg: exoplanet.orbit.meanAnomalyAtEpochDeg,
|
||||
epochJd: exoplanet.orbit.epochJd
|
||||
});
|
||||
const elements = resolveOrbitalElements(exoplanet.orbit);
|
||||
const radiusKm = exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined;
|
||||
const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, gmForParent(undefined), radiusKm);
|
||||
// Not `gmForParent(undefined)`: that assumes a solar-mass host for every system, and
|
||||
// most exoplanet hosts are red dwarfs a fraction of the Sun's mass.
|
||||
const gm = resolveGravitationalParameter({
|
||||
semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu,
|
||||
periodDays: exoplanet.periodDays,
|
||||
hostStarMassSolar: exoplanet.hostStarMassSolar
|
||||
});
|
||||
const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, gm, radiusKm, exoplanetFrame, appearanceForExoplanet(exoplanet, hostLuminositySolar));
|
||||
members.push({ id: exoplanet.id, kind: 'exoplanet', marker: tracked.marker });
|
||||
}
|
||||
|
||||
this.members = members;
|
||||
this.maxTopLevelSemiMajorAxisAu = this.topLevelBodies.reduce((max, body) => Math.max(max, body.elements.semiMajorAxisAu), 0);
|
||||
|
||||
// Which plane the system is read against follows from where its elements came from. Only the
|
||||
// Sun has Horizons bodies and no system has both, so this is a choice between the two rather
|
||||
// than a compromise: the ecliptic if there are solar-system bodies, the sky plane otherwise.
|
||||
this.referenceFrame = bodies.some((body) => !body.parentBodyId) ? ECLIPTIC_FRAME.clone() : exoplanetFrame;
|
||||
|
||||
const rings = systemGridRingsAu(this.maxTopLevelSemiMajorAxisAu);
|
||||
this.gridOuterRadiusAu = rings.length > 0 ? rings[rings.length - 1] : 0;
|
||||
if (rings.length > 0) {
|
||||
this.grid = new PolarGridPlane({
|
||||
ringRadii: rings,
|
||||
spokeCount: SYSTEM_GRID_SPOKES,
|
||||
orientation: this.referenceFrame,
|
||||
// Quieter and dashed, unlike the galaxy view's: here the grid shares a plane with the
|
||||
// orbit ellipses, which are themselves rings, and it must not be mistaken for one.
|
||||
opacity: SYSTEM_GRID_OPACITY,
|
||||
dashed: true,
|
||||
emphasisRadii: [rings[rings.length - 1]]
|
||||
});
|
||||
this.grid.setStrength(1);
|
||||
|
||||
this.tethers = new TetherField(this.topLevelBodies.length, {
|
||||
normal: new THREE.Vector3(0, 0, 1).applyQuaternion(this.referenceFrame),
|
||||
opacity: SYSTEM_TETHER_OPACITY
|
||||
});
|
||||
this.tethers.setStrength(1);
|
||||
this.tetherPoints = this.topLevelBodies.map((body) => body.position);
|
||||
|
||||
this.object.add(this.grid.object, this.tethers.object);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recomputes every marker's position for the given Julian date. Call once per tick. */
|
||||
update(epochJd: number): void {
|
||||
for (const body of this.topLevelBodies) {
|
||||
const { x, y, z } = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd);
|
||||
body.position.set(x, z, y); // AU "up" maps to scene Y, matching buildOrbitLine.
|
||||
const orbital = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd);
|
||||
body.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(body.frame);
|
||||
body.marker.position.copy(body.position);
|
||||
}
|
||||
|
||||
@@ -182,9 +310,13 @@ export class SystemOrbitsRenderer {
|
||||
continue;
|
||||
}
|
||||
moon.pivot.position.copy(parent.position);
|
||||
const { x, y, z } = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd);
|
||||
moon.marker.position.set(x, z, y);
|
||||
const orbital = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd);
|
||||
moon.marker.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(moon.frame);
|
||||
}
|
||||
|
||||
// Moons are left out: their tether would land within a marker's width of their planet's and
|
||||
// say nothing the planet's has not already said.
|
||||
this.tethers?.setTargets(this.tetherPoints);
|
||||
}
|
||||
|
||||
/** Looks up which system member a marker object belongs to (e.g. from a raycast hit). */
|
||||
@@ -198,34 +330,58 @@ export class SystemOrbitsRenderer {
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.grid?.dispose();
|
||||
this.tethers?.dispose();
|
||||
for (const { geometry, material } of this.disposables) {
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
}
|
||||
// Detach as well as dispose. A star-to-star hop builds a new renderer and drops the old
|
||||
// one, but without this the old orbit lines and markers stay parented to the system group
|
||||
// forever — still traversed and re-uploaded every frame despite their geometries being
|
||||
// disposed, and drawn over the new system while being unpickable.
|
||||
this.object.removeFromParent();
|
||||
this.object.clear();
|
||||
}
|
||||
|
||||
private addTopLevelBody(id: string, kind: SystemMemberKind, elements: OrbitalElements, gmAu3PerDay2: number, radiusKm: number | undefined): TrackedTopLevelBody {
|
||||
const orbitLine = buildOrbitLine(elements, kind);
|
||||
const marker = buildMarker(kind, radiusKm);
|
||||
private addTopLevelBody(
|
||||
id: string,
|
||||
kind: SystemMemberKind,
|
||||
elements: OrbitalElements,
|
||||
gmAu3PerDay2: number,
|
||||
radiusKm: number | undefined,
|
||||
frame: THREE.Quaternion,
|
||||
appearance?: PlanetAppearance
|
||||
): TrackedTopLevelBody {
|
||||
const orbitLine = buildOrbitLine(elements, kind, frame);
|
||||
const marker = buildMarker(kind, radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance);
|
||||
this.object.add(orbitLine, marker);
|
||||
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
||||
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
||||
|
||||
const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, position: new THREE.Vector3() };
|
||||
const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, frame, position: new THREE.Vector3() };
|
||||
this.topLevelBodies.push(tracked);
|
||||
return tracked;
|
||||
}
|
||||
|
||||
private addMoon(id: string, elements: OrbitalElements, gmAu3PerDay2: number, radiusKm: number | undefined, parent: TrackedTopLevelBody): TrackedMoon {
|
||||
private addMoon(
|
||||
id: string,
|
||||
elements: OrbitalElements,
|
||||
gmAu3PerDay2: number,
|
||||
radiusKm: number | undefined,
|
||||
parent: TrackedTopLevelBody,
|
||||
frame: THREE.Quaternion,
|
||||
appearance?: PlanetAppearance
|
||||
): TrackedMoon {
|
||||
const pivot = new THREE.Group();
|
||||
const orbitLine = buildOrbitLine(elements, 'moon');
|
||||
const marker = buildMarker('moon', radiusKm);
|
||||
const orbitLine = buildOrbitLine(elements, 'moon', frame);
|
||||
const marker = buildMarker('moon', radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance);
|
||||
pivot.add(orbitLine, marker);
|
||||
this.object.add(pivot);
|
||||
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
||||
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
||||
|
||||
const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, pivot, parentId: parent.id };
|
||||
const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, frame, pivot, parentId: parent.id };
|
||||
this.moons.push(moon);
|
||||
return moon;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSearchIndex, rankSearchResults, scoreSearchMatch, SearchEntry } from './search-ranking';
|
||||
|
||||
function star(name: string): SearchEntry {
|
||||
return { kind: 'star', name, subtitle: 'G2V', starId: name.length };
|
||||
}
|
||||
function body(name: string): SearchEntry {
|
||||
return { kind: 'body', name, subtitle: 'moon', bodyId: name };
|
||||
}
|
||||
function exoplanet(name: string): SearchEntry {
|
||||
return { kind: 'exoplanet', name, subtitle: 'host', bodyId: name };
|
||||
}
|
||||
|
||||
function rank(entries: readonly SearchEntry[], query: string, limit = 8): string[] {
|
||||
return rankSearchResults(buildSearchIndex(entries), query, limit).map((entry) => entry.name);
|
||||
}
|
||||
|
||||
describe('scoreSearchMatch', () => {
|
||||
it('ranks an exact match above a prefix, a prefix above a word start, and that above a substring', () => {
|
||||
const exact = scoreSearchMatch('Io', 'Io');
|
||||
const prefix = scoreSearchMatch('Iot Cas', 'Io');
|
||||
const wordStart = scoreSearchMatch('Alpha Ionis', 'Io');
|
||||
const substring = scoreSearchMatch('Bellion', 'io');
|
||||
|
||||
expect(exact).toBeGreaterThan(prefix);
|
||||
expect(prefix).toBeGreaterThan(wordStart);
|
||||
expect(wordStart).toBeGreaterThan(substring);
|
||||
expect(substring).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('ignores case', () => {
|
||||
expect(scoreSearchMatch('Sirius', 'sirius')).toBe(scoreSearchMatch('Sirius', 'Sirius'));
|
||||
});
|
||||
|
||||
it('ignores punctuation and spacing for an exact match', () => {
|
||||
// HYG writes "Gl 357"; a user may well type "gl357".
|
||||
expect(scoreSearchMatch('Gl 357', 'gl357')).toBe(scoreSearchMatch('Gl 357', 'Gl 357'));
|
||||
});
|
||||
|
||||
it('treats a hyphenated part as its own word', () => {
|
||||
// "Kepler-9 c" should be reachable by its designation as well as its catalogue name.
|
||||
expect(scoreSearchMatch('Kepler-9 c', '9')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not match an unrelated name', () => {
|
||||
expect(scoreSearchMatch('Sirius', 'zzz')).toBe(0);
|
||||
});
|
||||
|
||||
it('does not match an empty query', () => {
|
||||
expect(scoreSearchMatch('Sirius', '')).toBe(0);
|
||||
expect(scoreSearchMatch('Sirius', ' ')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankSearchResults', () => {
|
||||
it('surfaces an exact match that the old index-order scan could never reach', () => {
|
||||
// The moon Io sits behind 8750 stars in the index, so a scan that stopped at the first
|
||||
// 8 substring hits returned only stars named "Iot ..." and never reached it.
|
||||
const entries = [...Array.from({ length: 30 }, (_, i) => star(`Iot Star ${i}`)), body('Io')];
|
||||
|
||||
expect(rank(entries, 'Io')[0]).toBe('Io');
|
||||
});
|
||||
|
||||
it('keeps the rest of the matches after the exact one', () => {
|
||||
const entries = [star('Iot Cas'), body('Io'), star('Iot Boo')];
|
||||
expect(rank(entries, 'Io')).toEqual(['Io', 'Iot Boo', 'Iot Cas']);
|
||||
});
|
||||
|
||||
it('prefers a prefix match over a mid-name one', () => {
|
||||
expect(rank([star('Bellion'), star('Ionis')], 'io')).toEqual(['Ionis', 'Bellion']);
|
||||
});
|
||||
|
||||
it('orders equally-scored matches by kind, bodies first then stars then exoplanets', () => {
|
||||
const entries = [exoplanet('Cen x'), star('Cen y'), body('Cen z')];
|
||||
expect(rank(entries, 'Cen')).toEqual(['Cen z', 'Cen y', 'Cen x']);
|
||||
});
|
||||
|
||||
it('puts the host star ahead of its own planets', () => {
|
||||
const entries = [exoplanet('Proxima Cen b'), exoplanet('Proxima Cen d'), star('Proxima Centauri')];
|
||||
expect(rank(entries, 'Proxima')[0]).toBe('Proxima Centauri');
|
||||
});
|
||||
|
||||
it('breaks remaining ties by name length, then alphabetically', () => {
|
||||
const entries = [exoplanet('Kepler-1292 b'), exoplanet('Kepler-9 c'), exoplanet('Kepler-9 b'), exoplanet('Kepler-15 b')];
|
||||
expect(rank(entries, 'Kepler')).toEqual(['Kepler-9 b', 'Kepler-9 c', 'Kepler-15 b', 'Kepler-1292 b']);
|
||||
});
|
||||
|
||||
it('is independent of the order entries were indexed in', () => {
|
||||
const entries = [star('Iot Cas'), body('Io'), exoplanet('Iota b')];
|
||||
expect(rank(entries, 'Io')).toEqual(rank([...entries].reverse(), 'Io'));
|
||||
});
|
||||
|
||||
it('respects the limit', () => {
|
||||
const entries = Array.from({ length: 50 }, (_, i) => star(`Test ${i}`));
|
||||
expect(rank(entries, 'Test', 8)).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('returns nothing for a limit of zero or less', () => {
|
||||
expect(rank([star('Sirius')], 'Sirius', 0)).toEqual([]);
|
||||
expect(rank([star('Sirius')], 'Sirius', -1)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns nothing for an empty query rather than everything', () => {
|
||||
expect(rank([star('Sirius'), body('Io')], '')).toEqual([]);
|
||||
expect(rank([star('Sirius'), body('Io')], ' ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns nothing when there is no match', () => {
|
||||
expect(rank([star('Sirius')], 'zzz')).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles an empty index', () => {
|
||||
expect(rank([], 'anything')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps both stars that genuinely share a name', () => {
|
||||
// 17 HYG names are shared by two records — binary components are separate, visitable stars.
|
||||
const entries = [star('Iot Pic'), star('Iot Pic')];
|
||||
expect(rank(entries, 'Iot Pic')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('carries the full entry through, not just the name', () => {
|
||||
const [result] = rankSearchResults(buildSearchIndex([body('Io')]), 'Io', 1);
|
||||
expect(result).toMatchObject({ kind: 'body', name: 'Io', bodyId: 'Io' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSearchIndex', () => {
|
||||
it('preserves every entry', () => {
|
||||
const entries = [star('A'), body('B'), exoplanet('C')];
|
||||
expect(buildSearchIndex(entries).map((indexed) => indexed.entry)).toEqual(entries);
|
||||
});
|
||||
|
||||
it('precomputes the forms matching needs', () => {
|
||||
const [indexed] = buildSearchIndex([star('Alpha Cen-B')]);
|
||||
|
||||
expect(indexed.normalizedName).toBe('alpha cen-b');
|
||||
expect(indexed.compactName).toBe('alphacenb');
|
||||
expect(indexed.words).toEqual(['alpha', 'cen', 'b']);
|
||||
});
|
||||
|
||||
it('handles an empty list', () => {
|
||||
expect(buildSearchIndex([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
export type SearchResultKind = 'star' | 'body' | 'exoplanet';
|
||||
|
||||
export interface SearchEntry {
|
||||
kind: SearchResultKind;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
/** HYG star id, for `kind: 'star'` results. */
|
||||
starId?: number;
|
||||
/** `bodies.json`/`exoplanets.json` id, for `kind: 'body' | 'exoplanet'` results. */
|
||||
bodyId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How well a name matches, best first. The gaps are what matter: any exact match outranks every
|
||||
* prefix match, and so on, so a better kind of match can never be crowded out by a worse one.
|
||||
*/
|
||||
const MATCH_EXACT = 4;
|
||||
const MATCH_PREFIX = 3;
|
||||
const MATCH_WORD_START = 2;
|
||||
const MATCH_SUBSTRING = 1;
|
||||
const NO_MATCH = 0;
|
||||
|
||||
/**
|
||||
* Order for results that match equally well. Solar-system bodies are eighteen famous objects
|
||||
* and win ties outright; a star outranks an exoplanet because searching a name like "Proxima"
|
||||
* is usually an attempt to reach the system rather than one particular planet in it.
|
||||
*/
|
||||
const KIND_PRIORITY: Readonly<Record<SearchResultKind, number>> = {
|
||||
body: 0,
|
||||
star: 1,
|
||||
exoplanet: 2
|
||||
};
|
||||
|
||||
/** Catalogue names separate their parts with spaces, hyphens, underscores and slashes. */
|
||||
const WORD_SEPARATORS = /[\s\-_/]+/;
|
||||
|
||||
function normalize(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/** Strips everything but letters and digits, so "gj581" can match "GJ 581". */
|
||||
function compact(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* A search entry with its name pre-broken into the forms matching needs.
|
||||
*
|
||||
* Built once via {@link buildSearchIndex} rather than derived per keystroke. Normalising 15,000
|
||||
* names on every character typed costs about 11 ms — most of a frame — and the search runs on
|
||||
* the same thread as the render loop, so doing it live visibly stutters the scene.
|
||||
*/
|
||||
export interface IndexedSearchEntry {
|
||||
readonly entry: SearchEntry;
|
||||
readonly normalizedName: string;
|
||||
readonly compactName: string;
|
||||
readonly words: readonly string[];
|
||||
}
|
||||
|
||||
export function buildSearchIndex(entries: readonly SearchEntry[]): IndexedSearchEntry[] {
|
||||
return entries.map((entry) => {
|
||||
const normalizedName = normalize(entry.name);
|
||||
return {
|
||||
entry,
|
||||
normalizedName,
|
||||
compactName: compact(entry.name),
|
||||
words: normalizedName.split(WORD_SEPARATORS)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function scoreIndexed(indexed: IndexedSearchEntry, normalizedQuery: string, compactQuery: string): number {
|
||||
// Punctuation-insensitive as well as case-insensitive, because the catalogues are
|
||||
// inconsistent about it: HYG writes "Gl 357" where a user may well type "gl357".
|
||||
if (indexed.normalizedName === normalizedQuery || indexed.compactName === compactQuery) {
|
||||
return MATCH_EXACT;
|
||||
}
|
||||
if (indexed.normalizedName.startsWith(normalizedQuery)) {
|
||||
return MATCH_PREFIX;
|
||||
}
|
||||
if (indexed.words.some((word) => word.startsWith(normalizedQuery))) {
|
||||
return MATCH_WORD_START;
|
||||
}
|
||||
if (indexed.normalizedName.includes(normalizedQuery)) {
|
||||
return MATCH_SUBSTRING;
|
||||
}
|
||||
return NO_MATCH;
|
||||
}
|
||||
|
||||
/**
|
||||
* How well `name` matches `query`, or {@link NO_MATCH}. The readable, allocation-per-call form
|
||||
* of {@link scoreIndexed}, kept for tests and for callers scoring a single name.
|
||||
*/
|
||||
export function scoreSearchMatch(name: string, query: string): number {
|
||||
const normalizedQuery = normalize(query);
|
||||
if (normalizedQuery === '') {
|
||||
return NO_MATCH;
|
||||
}
|
||||
return scoreIndexed(buildSearchIndex([{ kind: 'star', name, subtitle: '' }])[0], normalizedQuery, compact(query));
|
||||
}
|
||||
|
||||
/**
|
||||
* The `limit` best matches for `query`, best first.
|
||||
*
|
||||
* Replaces a scan that took the first `limit` substring matches in index order — stars, then
|
||||
* bodies, then exoplanets. With 8750 stars ahead of 18 bodies, that let a worse match hide a
|
||||
* better one *and* an exact match: searching "Io" returned eight stars named "Iot ..." and
|
||||
* never reached the moon Io at all, because the scan had already filled up.
|
||||
*
|
||||
* Everything is scored before anything is taken, so the best matches win regardless of where
|
||||
* they sit in the index. Ties break on kind, then on name length — a shorter name containing
|
||||
* the query is the closer match — and finally alphabetically, so the order is fully determined
|
||||
* rather than dependent on the input order.
|
||||
*/
|
||||
export function rankSearchResults(index: readonly IndexedSearchEntry[], query: string, limit: number): SearchEntry[] {
|
||||
const normalizedQuery = normalize(query);
|
||||
if (limit <= 0 || normalizedQuery === '') {
|
||||
return [];
|
||||
}
|
||||
const compactQuery = compact(query);
|
||||
|
||||
const scored: { entry: SearchEntry; score: number }[] = [];
|
||||
for (const indexed of index) {
|
||||
const score = scoreIndexed(indexed, normalizedQuery, compactQuery);
|
||||
if (score > NO_MATCH) {
|
||||
scored.push({ entry: indexed.entry, score });
|
||||
}
|
||||
}
|
||||
|
||||
scored.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
KIND_PRIORITY[a.entry.kind] - KIND_PRIORITY[b.entry.kind] ||
|
||||
a.entry.name.length - b.entry.name.length ||
|
||||
a.entry.name.localeCompare(b.entry.name)
|
||||
);
|
||||
|
||||
return scored.slice(0, limit).map((match) => match.entry);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DataLoaderService, StarField } from '../../core/data/data-loader.service';
|
||||
import { BodyRecord } from '../../shared/models/body.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
import { SearchComponent } from './search.component';
|
||||
|
||||
function starRecord(id: number, name: string): StarRecord {
|
||||
return { id, name, x: 0, y: 0, z: 0, magnitude: 5, spectralType: 'G2V', colorIndex: 0.65 };
|
||||
}
|
||||
|
||||
/** Enough "Iot ..." stars to fill the result list ahead of the moon Io, as the real index does. */
|
||||
const STARS: StarRecord[] = [
|
||||
...Array.from({ length: 12 }, (_, i) => starRecord(100 + i, `Iot Star ${i}`)),
|
||||
starRecord(1, 'Proxima Centauri')
|
||||
];
|
||||
|
||||
const IO: BodyRecord = {
|
||||
id: 'io',
|
||||
systemStarId: 0,
|
||||
name: 'Io',
|
||||
kind: 'moon',
|
||||
parentBodyId: 'jupiter',
|
||||
radiusKm: 1821,
|
||||
orbit: {
|
||||
semiMajorAxisAu: 0.002819,
|
||||
eccentricity: 0.004,
|
||||
inclinationDeg: 0,
|
||||
longitudeOfAscendingNodeDeg: 0,
|
||||
argumentOfPeriapsisDeg: 0,
|
||||
meanAnomalyAtEpochDeg: 0,
|
||||
epochJd: 2451545.0
|
||||
}
|
||||
};
|
||||
|
||||
const PROXIMA_B: ExoplanetRecord = {
|
||||
id: 'Proxima Cen b',
|
||||
hostStarId: 1,
|
||||
hostStarName: 'Proxima Centauri',
|
||||
name: 'Proxima Cen b',
|
||||
orbit: { semiMajorAxisAu: 0.0485, eccentricity: 0.02 }
|
||||
};
|
||||
|
||||
class FakeDataLoaderService {
|
||||
loadStars(): Promise<StarField> {
|
||||
return Promise.resolve({ stars: STARS, positions: new Float32Array(STARS.length * 3) });
|
||||
}
|
||||
loadBodies(): Promise<BodyRecord[]> {
|
||||
return Promise.resolve([IO]);
|
||||
}
|
||||
loadExoplanets(): Promise<ExoplanetRecord[]> {
|
||||
return Promise.resolve([PROXIMA_B]);
|
||||
}
|
||||
}
|
||||
|
||||
describe('SearchComponent', () => {
|
||||
let fixture: ComponentFixture<SearchComponent>;
|
||||
let element: HTMLElement;
|
||||
let navigationStore: NavigationStore;
|
||||
let router: { navigate: ReturnType<typeof vi.fn> };
|
||||
|
||||
async function type(query: string): Promise<void> {
|
||||
fixture.componentInstance.query.set(query);
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
function resultNames(): string[] {
|
||||
return [...element.querySelectorAll('[data-testid="search-results"] button')].map((button) =>
|
||||
(button.querySelector('span')?.textContent ?? '').trim()
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
router = { navigate: vi.fn().mockResolvedValue(true) };
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SearchComponent],
|
||||
providers: [
|
||||
{ provide: DataLoaderService, useClass: FakeDataLoaderService },
|
||||
{ provide: Router, useValue: router }
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SearchComponent);
|
||||
navigationStore = TestBed.inject(NavigationStore);
|
||||
await fixture.whenStable();
|
||||
element = fixture.nativeElement as HTMLElement;
|
||||
});
|
||||
|
||||
it('shows no results until the query is long enough', async () => {
|
||||
await type('I');
|
||||
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('finds an exact match that sits behind thousands of stars in the index', async () => {
|
||||
// The regression this ranking exists for: the moon Io is indexed after every star, so the
|
||||
// old first-8-substring-hits scan filled up on "Iot ..." names and never reached it.
|
||||
await type('Io');
|
||||
expect(resultNames()[0]).toBe('Io');
|
||||
});
|
||||
|
||||
it('puts a host star ahead of its own planets', async () => {
|
||||
await type('Proxima');
|
||||
expect(resultNames()[0]).toBe('Proxima Centauri');
|
||||
expect(resultNames()).toContain('Proxima Cen b');
|
||||
});
|
||||
|
||||
it('shows nothing for a query that matches nothing', async () => {
|
||||
await type('zzzzz');
|
||||
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('selects a star and returns to the galaxy route', async () => {
|
||||
await type('Proxima Centauri');
|
||||
element.querySelector<HTMLButtonElement>('[data-testid="search-results"] button')!.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(navigationStore.selectedStarId()).toBe(1);
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||
});
|
||||
|
||||
it('navigates straight to a body detail route', async () => {
|
||||
await type('Io');
|
||||
element.querySelector<HTMLButtonElement>('[data-testid="search-results"] button')!.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/body', 'io']);
|
||||
});
|
||||
|
||||
it('clears the query after a selection, so the list closes', async () => {
|
||||
await type('Io');
|
||||
element.querySelector<HTMLButtonElement>('[data-testid="search-results"] button')!.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(fixture.componentInstance.query()).toBe('');
|
||||
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears the query on demand', async () => {
|
||||
await type('Io');
|
||||
fixture.componentInstance.clear();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,18 +3,7 @@ import { Router } from '@angular/router';
|
||||
|
||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
|
||||
type SearchResultKind = 'star' | 'body' | 'exoplanet';
|
||||
|
||||
interface SearchEntry {
|
||||
kind: SearchResultKind;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
/** HYG star id, for `kind: 'star'` results. */
|
||||
starId?: number;
|
||||
/** `bodies.json`/`exoplanets.json` id, for `kind: 'body' | 'exoplanet'` results. */
|
||||
bodyId?: string;
|
||||
}
|
||||
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults, SearchEntry, SearchResultKind } from './search-ranking';
|
||||
|
||||
const MAX_RESULTS = 8;
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
@@ -70,23 +59,15 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
|
||||
})
|
||||
export class SearchComponent {
|
||||
readonly query = signal('');
|
||||
private readonly index = signal<SearchEntry[]>([]);
|
||||
/** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */
|
||||
private readonly index = signal<IndexedSearchEntry[]>([]);
|
||||
|
||||
readonly results = computed(() => {
|
||||
const query = this.query().trim().toLowerCase();
|
||||
const query = this.query().trim();
|
||||
if (query.length < MIN_QUERY_LENGTH) {
|
||||
return [];
|
||||
}
|
||||
const matches: SearchEntry[] = [];
|
||||
for (const entry of this.index()) {
|
||||
if (entry.name.toLowerCase().includes(query)) {
|
||||
matches.push(entry);
|
||||
if (matches.length >= MAX_RESULTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
return rankSearchResults(this.index(), query, MAX_RESULTS);
|
||||
});
|
||||
|
||||
constructor(
|
||||
@@ -133,7 +114,7 @@ export class SearchComponent {
|
||||
...exoplanets.map((exoplanet): SearchEntry => ({ kind: 'exoplanet', name: exoplanet.name, subtitle: exoplanet.hostStarName, bodyId: exoplanet.id }))
|
||||
];
|
||||
|
||||
this.index.set(entries);
|
||||
this.index.set(buildSearchIndex(entries));
|
||||
} catch (error) {
|
||||
console.error('Failed to build the search index.', error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BodyRecord } from '../models/body.model';
|
||||
import { ExoplanetRecord } from '../models/exoplanet.model';
|
||||
import { appearanceForBody, appearanceForExoplanet, heliocentricDistanceAu } from './body-appearance';
|
||||
import { DEFAULT_EPOCH_JD } from './constants';
|
||||
|
||||
const ORBIT = { eccentricity: 0, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD };
|
||||
|
||||
const JUPITER: BodyRecord = { id: 'jupiter', systemStarId: 0, name: 'Jupiter', kind: 'planet', radiusKm: 69911, orbit: { ...ORBIT, semiMajorAxisAu: 5.204 } };
|
||||
/** Europa's own orbit is around Jupiter: 671,000 km, which is 0.00449 AU. */
|
||||
const EUROPA: BodyRecord = { id: 'europa', systemStarId: 0, name: 'Europa', kind: 'moon', radiusKm: 1560, parentBodyId: 'jupiter', orbit: { ...ORBIT, semiMajorAxisAu: 0.00449 } };
|
||||
const EARTH: BodyRecord = { id: 'earth', systemStarId: 0, name: 'Earth', kind: 'planet', radiusKm: 6371, orbit: { ...ORBIT, semiMajorAxisAu: 1 } };
|
||||
const ORPHAN: BodyRecord = { ...EUROPA, id: 'orphan', parentBodyId: 'nowhere' };
|
||||
|
||||
const BODIES = [JUPITER, EUROPA, EARTH, ORPHAN];
|
||||
|
||||
describe('heliocentricDistanceAu', () => {
|
||||
it('uses a planet own orbit', () => {
|
||||
expect(heliocentricDistanceAu(JUPITER, BODIES)).toBeCloseTo(5.204, 6);
|
||||
});
|
||||
|
||||
it('uses a moon parent orbit, not the moon own', () => {
|
||||
// The load-bearing case: Europa's own semi-major axis is 0.0045 AU. Fed to an equilibrium
|
||||
// temperature it would put Europa closer to the Sun than Mercury and boil it.
|
||||
expect(heliocentricDistanceAu(EUROPA, BODIES)).toBeCloseTo(5.204, 6);
|
||||
});
|
||||
|
||||
it('has no answer for a moon whose parent is missing', () => {
|
||||
expect(heliocentricDistanceAu(ORPHAN, BODIES)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('appearanceForBody', () => {
|
||||
it('derives an ice world for a moon of Jupiter, at Jupiter distance', () => {
|
||||
const europa = appearanceForBody(EUROPA, BODIES, 1);
|
||||
|
||||
expect(europa.equilibriumTemperatureK).toBeCloseTo(112, -0.5);
|
||||
expect(europa.planetClass).toBe('icy');
|
||||
});
|
||||
|
||||
it('would have melted that same moon if it used the moon own orbit', () => {
|
||||
// Pinning the bug the parent lookup exists to avoid, so it cannot come back silently.
|
||||
const wrong = appearanceForBody({ ...EUROPA, parentBodyId: undefined }, BODIES, 1);
|
||||
expect(wrong.equilibriumTemperatureK!).toBeGreaterThan(2000);
|
||||
expect(wrong.planetClass).not.toBe('icy');
|
||||
});
|
||||
|
||||
it('derives Earth as temperate with a polar cap', () => {
|
||||
const earth = appearanceForBody(EARTH, BODIES, 1);
|
||||
|
||||
expect(earth.planetClass).toBe('temperate');
|
||||
expect(earth.equilibriumTemperatureK).toBeCloseTo(255, -0.5);
|
||||
expect(earth.polarCapExtentDeg).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('has no density for a solar-system body, since Horizons publishes no masses', () => {
|
||||
expect(appearanceForBody(EARTH, BODIES, 1).bulkDensityGramsPerCm3).toBeNull();
|
||||
});
|
||||
|
||||
it('still classifies a body when the host luminosity is unknown', () => {
|
||||
const earth = appearanceForBody(EARTH, BODIES, null);
|
||||
expect(earth.equilibriumTemperatureK).toBeNull();
|
||||
expect(earth.planetClass).toBe('rocky');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appearanceForExoplanet', () => {
|
||||
const KEPLER_186F: ExoplanetRecord = { id: 'Kepler-186 f', hostStarId: 1, hostStarName: 'Kepler-186', name: 'Kepler-186 f', radiusEarth: 1.17, orbit: { semiMajorAxisAu: 0.432 } };
|
||||
|
||||
it('derives a temperature from the host star output and the published orbit', () => {
|
||||
// A quarter of a solar luminosity at 0.432 AU: cool, but not frozen.
|
||||
const derived = appearanceForExoplanet(KEPLER_186F, 0.04);
|
||||
expect(derived.equilibriumTemperatureK).toBeGreaterThan(150);
|
||||
expect(derived.equilibriumTemperatureK).toBeLessThan(250);
|
||||
});
|
||||
|
||||
it('derives a density where both a radius and a mass are published', () => {
|
||||
const withMass = appearanceForExoplanet({ ...KEPLER_186F, massEarth: 1.4 }, 0.04);
|
||||
expect(withMass.bulkDensityGramsPerCm3).toBeCloseTo((5.51 * 1.4) / Math.pow(1.17, 3), 4);
|
||||
});
|
||||
|
||||
it('falls back to size alone for a host that never cross-referenced to the catalogue', () => {
|
||||
// 5685 of the 6319 archive records have no matching HYG star. None of them is rendered in a
|
||||
// system, but any of them can still be opened from search.
|
||||
const derived = appearanceForExoplanet({ ...KEPLER_186F, hostStarId: null }, null);
|
||||
expect(derived.equilibriumTemperatureK).toBeNull();
|
||||
expect(derived.planetClass).toBe('rocky');
|
||||
});
|
||||
|
||||
it('is stable per planet, so a world keeps its face between visits', () => {
|
||||
expect(appearanceForExoplanet(KEPLER_186F, 0.04).seed).toBe(appearanceForExoplanet(KEPLER_186F, 0.04).seed);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BodyRecord } from '../models/body.model';
|
||||
import { ExoplanetRecord } from '../models/exoplanet.model';
|
||||
import { EARTH_RADIUS_KM, PlanetAppearance, planetAppearance } from './planet-appearance';
|
||||
|
||||
/**
|
||||
* Adapters from the two record shapes this app carries to the appearance derivation.
|
||||
*
|
||||
* They exist because the two sources publish different things. The Exoplanet Archive gives a
|
||||
* radius and a mass in Earth units and a semi-major axis around the host star. Horizons gives a
|
||||
* radius in kilometres, no mass at all, and — for a moon — a semi-major axis around its
|
||||
* *planet* rather than around the Sun. Feeding a moon's own orbit into an equilibrium
|
||||
* temperature would put Europa a few thousandths of an AU from the Sun and melt it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Distance from the host star at which a body actually sits, in AU.
|
||||
*
|
||||
* For a moon that is its planet's distance, not its own: the tiny orbit around the planet is
|
||||
* irrelevant to how much starlight reaches it, and using it would be off by three orders of
|
||||
* magnitude.
|
||||
*/
|
||||
export function heliocentricDistanceAu(body: BodyRecord, bodies: readonly BodyRecord[]): number | undefined {
|
||||
if (!body.parentBodyId) {
|
||||
return body.orbit.semiMajorAxisAu;
|
||||
}
|
||||
return bodies.find((candidate) => candidate.id === body.parentBodyId)?.orbit.semiMajorAxisAu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appearance of a solar-system body.
|
||||
*
|
||||
* Horizons publishes no masses, so these worlds have no derived density and are classified on
|
||||
* size and temperature alone. That is enough for what it decides here: at Jupiter's distance
|
||||
* from the Sun the question is never whether a moon is rock or iron, it is whether its surface
|
||||
* is ice, and the temperature answers that on its own.
|
||||
*/
|
||||
export function appearanceForBody(body: BodyRecord, bodies: readonly BodyRecord[], hostLuminositySolar: number | null | undefined): PlanetAppearance {
|
||||
return planetAppearance({
|
||||
id: body.id,
|
||||
radiusEarth: body.radiusKm ? body.radiusKm / EARTH_RADIUS_KM : undefined,
|
||||
semiMajorAxisAu: heliocentricDistanceAu(body, bodies),
|
||||
hostLuminositySolar
|
||||
});
|
||||
}
|
||||
|
||||
/** Appearance of an exoplanet, from the archive's published radius, mass and orbit. */
|
||||
export function appearanceForExoplanet(exoplanet: ExoplanetRecord, hostLuminositySolar: number | null | undefined): PlanetAppearance {
|
||||
return planetAppearance({
|
||||
id: exoplanet.id,
|
||||
radiusEarth: exoplanet.radiusEarth,
|
||||
massEarth: exoplanet.massEarth,
|
||||
semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu,
|
||||
hostLuminositySolar
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { distanceBetween, parallaxMasToParsecs, raDecDistanceToXyz, raDegDecDistanceToXyz } from './coordinates';
|
||||
import {
|
||||
distanceBetween,
|
||||
eclipticToEquatorial,
|
||||
equatorialToEcliptic,
|
||||
OBLIQUITY_J2000_DEG,
|
||||
parallaxMasToParsecs,
|
||||
parseSexagesimal,
|
||||
raDecDistanceToXyz,
|
||||
raDecToUnitVector,
|
||||
raDegDecDistanceToXyz
|
||||
} from './coordinates';
|
||||
|
||||
// Reference values taken directly from the HYG v4.1 database (RA/Dec/dist and its own
|
||||
// precomputed x/y/z, which uses the same equatorial-Cartesian convention we implement).
|
||||
@@ -65,3 +75,132 @@ describe('distanceBetween', () => {
|
||||
expect(distanceBetween({ x: 0, y: 0, z: 0 }, { x: 3, y: 4, z: 0 })).toBeCloseTo(5, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('raDecToUnitVector', () => {
|
||||
it('always returns a unit-length vector', () => {
|
||||
for (const [ra, dec] of [
|
||||
[0, 0],
|
||||
[6, 45],
|
||||
[13.7, -62.7],
|
||||
[23.99, 89.9]
|
||||
]) {
|
||||
const { x, y, z } = raDecToUnitVector(ra, dec);
|
||||
expect(Math.hypot(x, y, z)).toBeCloseTo(1, 12);
|
||||
}
|
||||
});
|
||||
|
||||
it('points along +Z at the north celestial pole', () => {
|
||||
const { x, y, z } = raDecToUnitVector(0, 90);
|
||||
expect(x).toBeCloseTo(0, 12);
|
||||
expect(y).toBeCloseTo(0, 12);
|
||||
expect(z).toBeCloseTo(1, 12);
|
||||
});
|
||||
|
||||
it('agrees with the distance-carrying conversion, scaled', () => {
|
||||
const unit = raDecToUnitVector(6.752481, -16.716116);
|
||||
const scaled = raDecDistanceToXyz(6.752481, -16.716116, 2.6371);
|
||||
|
||||
expect(unit.x * 2.6371).toBeCloseTo(scaled.x, 12);
|
||||
expect(unit.y * 2.6371).toBeCloseTo(scaled.y, 12);
|
||||
expect(unit.z * 2.6371).toBeCloseTo(scaled.z, 12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSexagesimal', () => {
|
||||
it('parses a right ascension into decimal hours', () => {
|
||||
// 00:08:27.05 = 8/60 + 27.05/3600 hours
|
||||
expect(parseSexagesimal('00:08:27.05')).toBeCloseTo(0.140847, 6);
|
||||
});
|
||||
|
||||
it('parses a positive declination into decimal degrees', () => {
|
||||
expect(parseSexagesimal('+27:43:03.6')).toBeCloseTo(27.7176667, 6);
|
||||
});
|
||||
|
||||
it('parses a negative declination', () => {
|
||||
expect(parseSexagesimal('-12:49:22.3')).toBeCloseTo(-12.8228611, 6);
|
||||
});
|
||||
|
||||
it('keeps the sign for a negative angle inside the first degree', () => {
|
||||
// The trap: `Number('-00')` is `-0`, which is `=== 0`, so a naive implementation flips
|
||||
// this object into the northern hemisphere.
|
||||
const parsed = parseSexagesimal('-00:24:54.8');
|
||||
expect(parsed).toBeLessThan(0);
|
||||
expect(parsed).toBeCloseTo(-0.4152222, 6);
|
||||
});
|
||||
|
||||
it('treats an unsigned angle as positive', () => {
|
||||
expect(parseSexagesimal('00:24:54.8')).toBeCloseTo(0.4152222, 6);
|
||||
});
|
||||
|
||||
it('tolerates surrounding whitespace', () => {
|
||||
expect(parseSexagesimal(' +27:43:03.6 ')).toBeCloseTo(27.7176667, 6);
|
||||
});
|
||||
|
||||
it('returns null for missing or malformed values', () => {
|
||||
for (const input of ['', ' ', 'not-an-angle', '12:34', '12:34:56:78', '12;34;56', undefined, null]) {
|
||||
expect(parseSexagesimal(input)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null rather than a partial value for empty sub-fields', () => {
|
||||
expect(parseSexagesimal('12::56')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('eclipticToEquatorial', () => {
|
||||
const RAD = Math.PI / 180;
|
||||
|
||||
it('leaves the vernal equinox untouched, since both frames share that axis', () => {
|
||||
// +X is where the ecliptic crosses the celestial equator, so it is the rotation axis.
|
||||
expect(eclipticToEquatorial({ x: 1, y: 0, z: 0 })).toEqual({ x: 1, y: 0, z: 0 });
|
||||
});
|
||||
|
||||
it('puts the ecliptic pole the obliquity away from the celestial pole', () => {
|
||||
const pole = eclipticToEquatorial({ x: 0, y: 0, z: 1 });
|
||||
const angleFromCelestialPoleDeg = Math.acos(pole.z) / RAD;
|
||||
|
||||
expect(angleFromCelestialPoleDeg).toBeCloseTo(OBLIQUITY_J2000_DEG, 9);
|
||||
expect(pole.x).toBeCloseTo(0, 12);
|
||||
expect(pole.y).toBeCloseTo(-Math.sin(OBLIQUITY_J2000_DEG * RAD), 12);
|
||||
});
|
||||
|
||||
it('places the summer solstice point at the obliquity in declination', () => {
|
||||
// Ecliptic longitude 90 degrees is the northernmost point of the Sun's yearly path, whose
|
||||
// declination is by definition the obliquity — about 23.4 degrees.
|
||||
const solstice = eclipticToEquatorial({ x: 0, y: 1, z: 0 });
|
||||
const declinationDeg = Math.asin(solstice.z) / RAD;
|
||||
|
||||
expect(declinationDeg).toBeCloseTo(OBLIQUITY_J2000_DEG, 9);
|
||||
});
|
||||
|
||||
it('preserves length, being a rotation', () => {
|
||||
const rotated = eclipticToEquatorial({ x: 0.3, y: -0.5, z: 0.81 });
|
||||
expect(Math.hypot(rotated.x, rotated.y, rotated.z)).toBeCloseTo(Math.hypot(0.3, -0.5, 0.81), 12);
|
||||
});
|
||||
|
||||
it('leaves a point in the ecliptic plane in that plane, tilted out of the equator', () => {
|
||||
const inPlane = eclipticToEquatorial({ x: 0.6, y: 0.8, z: 0 });
|
||||
expect(inPlane.z).toBeCloseTo(0.8 * Math.sin(OBLIQUITY_J2000_DEG * RAD), 12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equatorialToEcliptic', () => {
|
||||
it('is the exact inverse of eclipticToEquatorial', () => {
|
||||
for (const point of [
|
||||
{ x: 1, y: 0, z: 0 },
|
||||
{ x: 0, y: 1, z: 0 },
|
||||
{ x: 0, y: 0, z: 1 },
|
||||
{ x: -0.37, y: 0.42, z: 0.83 }
|
||||
]) {
|
||||
const round = equatorialToEcliptic(eclipticToEquatorial(point));
|
||||
expect(round.x).toBeCloseTo(point.x, 12);
|
||||
expect(round.y).toBeCloseTo(point.y, 12);
|
||||
expect(round.z).toBeCloseTo(point.z, 12);
|
||||
}
|
||||
});
|
||||
|
||||
it('brings the celestial pole back to the obliquity off the ecliptic pole', () => {
|
||||
const pole = equatorialToEcliptic({ x: 0, y: 0, z: 1 });
|
||||
expect(Math.acos(pole.z) / (Math.PI / 180)).toBeCloseTo(OBLIQUITY_J2000_DEG, 9);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,83 @@ export function raDegDecDistanceToXyz(raDeg: number, decDeg: number, distancePc:
|
||||
return raDecDistanceToXyz(raDeg / HOURS_TO_DEG, decDeg, distancePc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obliquity of the ecliptic at J2000.0, in degrees — the tilt of Earth's orbital plane against
|
||||
* its equator, and so the angle between this app's two source frames.
|
||||
*/
|
||||
export const OBLIQUITY_J2000_DEG = 23.4392911;
|
||||
|
||||
/**
|
||||
* Rotates a vector from the **ecliptic** frame into the **equatorial** frame, both J2000.
|
||||
*
|
||||
* The app has to span both because its two sources disagree. Star positions come from HYG as
|
||||
* equatorial coordinates, which `raDecDistanceToXyz` produces and which the galaxy view renders
|
||||
* directly. Orbital elements come from JPL Horizons, whose default reference plane for element
|
||||
* output is the ecliptic — the ETL never overrides it. The two are tilted
|
||||
* {@link OBLIQUITY_J2000_DEG} apart about the shared vernal-equinox axis, so orbits have to be
|
||||
* rotated before they can share a scene with the stars.
|
||||
*/
|
||||
export function eclipticToEquatorial(position: CartesianCoordinates): CartesianCoordinates {
|
||||
const obliquity = OBLIQUITY_J2000_DEG * DEG_TO_RAD;
|
||||
const cos = Math.cos(obliquity);
|
||||
const sin = Math.sin(obliquity);
|
||||
|
||||
// A rotation about +X, which both frames share: it points at the vernal equinox, where the
|
||||
// ecliptic and the celestial equator cross.
|
||||
return {
|
||||
x: position.x,
|
||||
y: position.y * cos - position.z * sin,
|
||||
z: position.y * sin + position.z * cos
|
||||
};
|
||||
}
|
||||
|
||||
/** Inverse of {@link eclipticToEquatorial}. */
|
||||
export function equatorialToEcliptic(position: CartesianCoordinates): CartesianCoordinates {
|
||||
const obliquity = OBLIQUITY_J2000_DEG * DEG_TO_RAD;
|
||||
const cos = Math.cos(obliquity);
|
||||
const sin = Math.sin(obliquity);
|
||||
|
||||
return {
|
||||
x: position.x,
|
||||
y: position.y * cos + position.z * sin,
|
||||
z: -position.y * sin + position.z * cos
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Direction to a point on the celestial sphere as a unit vector, in the same equatorial frame
|
||||
* as {@link raDecDistanceToXyz}. Used for sources whose distance is unknown or irrelevant —
|
||||
* e.g. deep-sky objects rendered as a backdrop, where only the line of sight matters.
|
||||
*/
|
||||
export function raDecToUnitVector(raHours: number, decDeg: number): CartesianCoordinates {
|
||||
return raDecDistanceToXyz(raHours, decDeg, 1);
|
||||
}
|
||||
|
||||
const SEXAGESIMAL_PATTERN = /^([+-])?(\d+):(\d+):(\d+(?:\.\d+)?)$/;
|
||||
|
||||
/**
|
||||
* Parses a sexagesimal angle ("HH:MM:SS.ss" or "+DD:MM:SS.s", as published by OpenNGC and
|
||||
* most catalogs) into a decimal value carrying the unit of its first field — hours for right
|
||||
* ascension, degrees for declination.
|
||||
*
|
||||
* The sign is read from the string rather than from the parsed degrees field: `Number('-00')`
|
||||
* is `-0`, which compares equal to `0`, so a declination like "-00:24:54.8" would otherwise
|
||||
* come out positive and place the object in the wrong hemisphere.
|
||||
*
|
||||
* Returns `null` for anything that isn't a well-formed sexagesimal triple, including the empty
|
||||
* strings that catalogs use for missing values.
|
||||
*/
|
||||
export function parseSexagesimal(text: string | undefined | null): number | null {
|
||||
const match = SEXAGESIMAL_PATTERN.exec((text ?? '').trim());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, sign, degreesOrHours, minutes, seconds] = match;
|
||||
const magnitude = Number(degreesOrHours) + Number(minutes) / 60 + Number(seconds) / 3600;
|
||||
return sign === '-' ? -magnitude : magnitude;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a parallax (milliarcseconds) into a distance in parsecs.
|
||||
* Returns `Infinity` for non-positive parallax (unmeasured/negative parallax).
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
classifyOpenNgcType,
|
||||
estimateDeepSkyDistancePc,
|
||||
HUBBLE_CONSTANT_KM_S_PER_MPC,
|
||||
isNotableDeepSkyObject,
|
||||
NOTABLE_MAGNITUDE_LIMIT,
|
||||
redshiftToDistancePc,
|
||||
SPEED_OF_LIGHT_KM_S
|
||||
} from './deep-sky';
|
||||
|
||||
describe('classifyOpenNgcType', () => {
|
||||
it('groups every galaxy-ish type as a galaxy', () => {
|
||||
for (const type of ['G', 'GPair', 'GTrpl', 'GGroup']) {
|
||||
expect(classifyOpenNgcType(type)).toBe('galaxy');
|
||||
}
|
||||
});
|
||||
|
||||
it('groups nebulae, remnants and cluster-with-nebulosity as nebulae', () => {
|
||||
for (const type of ['PN', 'HII', 'EmN', 'RfN', 'Neb', 'DrkN', 'SNR', 'Cl+N']) {
|
||||
expect(classifyOpenNgcType(type)).toBe('nebula');
|
||||
}
|
||||
});
|
||||
|
||||
it('groups open, globular and stellar-association types as clusters', () => {
|
||||
for (const type of ['OCl', 'GCl', '*Ass']) {
|
||||
expect(classifyOpenNgcType(type)).toBe('cluster');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects catalog rows that are not deep-sky objects', () => {
|
||||
// Duplicates, non-existent entries, plain and double stars, novae, and the catch-all.
|
||||
for (const type of ['Dup', 'NonEx', '*', '**', 'Nova', 'Other']) {
|
||||
expect(classifyOpenNgcType(type)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects missing or unknown types', () => {
|
||||
for (const type of ['', ' ', 'wat', undefined, null]) {
|
||||
expect(classifyOpenNgcType(type)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores surrounding whitespace', () => {
|
||||
expect(classifyOpenNgcType(' G ')).toBe('galaxy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('redshiftToDistancePc', () => {
|
||||
it('applies the Hubble law', () => {
|
||||
const redshift = 0.02286;
|
||||
const expectedMpc = (SPEED_OF_LIGHT_KM_S * redshift) / HUBBLE_CONSTANT_KM_S_PER_MPC;
|
||||
expect(redshiftToDistancePc(redshift)).toBeCloseTo(expectedMpc * 1e6, 0);
|
||||
});
|
||||
|
||||
it('scales linearly with redshift', () => {
|
||||
const near = redshiftToDistancePc(0.01)!;
|
||||
const far = redshiftToDistancePc(0.02)!;
|
||||
expect(far / near).toBeCloseTo(2, 9);
|
||||
});
|
||||
|
||||
it('rejects a blueshift, which carries no distance information', () => {
|
||||
// M31 approaches us at ~300 km/s.
|
||||
expect(redshiftToDistancePc(-0.001)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects redshifts too small to be dominated by cosmological expansion', () => {
|
||||
// The Small Magellanic Cloud: a real positive redshift that yields a 33x-wrong distance.
|
||||
expect(redshiftToDistancePc(0.000527)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects null and non-finite input', () => {
|
||||
expect(redshiftToDistancePc(null)).toBeNull();
|
||||
expect(redshiftToDistancePc(Number.NaN)).toBeNull();
|
||||
expect(redshiftToDistancePc(Number.POSITIVE_INFINITY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateDeepSkyDistancePc', () => {
|
||||
it('prefers parallax for a galactic object', () => {
|
||||
// The Helix Nebula, ~200 pc away.
|
||||
const estimate = estimateDeepSkyDistancePc({ kind: 'nebula', redshift: 0.05, parallaxMas: 4.98 });
|
||||
expect(estimate?.method).toBe('parallax');
|
||||
expect(estimate?.distancePc).toBeCloseTo(200.8, 1);
|
||||
});
|
||||
|
||||
it('refuses parallax for a galaxy, because the catalog value is a foreground star', () => {
|
||||
// OpenNGC lists 6 mas for M31 — that would put a 780 kpc galaxy at 167 pc.
|
||||
const estimate = estimateDeepSkyDistancePc({ kind: 'galaxy', redshift: -0.001, parallaxMas: 6 });
|
||||
expect(estimate).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to redshift for a distant galaxy', () => {
|
||||
const estimate = estimateDeepSkyDistancePc({ kind: 'galaxy', redshift: 0.00365, parallaxMas: null });
|
||||
expect(estimate?.method).toBe('redshift');
|
||||
expect(estimate!.distancePc).toBeGreaterThan(1e7);
|
||||
});
|
||||
|
||||
it('falls back to redshift when a galactic object has no usable parallax', () => {
|
||||
for (const parallaxMas of [null, 0, -3]) {
|
||||
const estimate = estimateDeepSkyDistancePc({ kind: 'cluster', redshift: 0.01, parallaxMas });
|
||||
expect(estimate?.method).toBe('redshift');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a parallax implying a distance beyond the Milky Way', () => {
|
||||
// 0.000001 mas would imply a billion parsecs — noise, not a measurement.
|
||||
const estimate = estimateDeepSkyDistancePc({ kind: 'cluster', redshift: null, parallaxMas: 1e-6 });
|
||||
expect(estimate).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when neither source is usable', () => {
|
||||
expect(estimateDeepSkyDistancePc({ kind: 'nebula', redshift: null, parallaxMas: null })).toBeNull();
|
||||
expect(estimateDeepSkyDistancePc({ kind: 'galaxy', redshift: null, parallaxMas: null })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNotableDeepSkyObject', () => {
|
||||
it('keeps anything in the Messier catalog, however faint', () => {
|
||||
expect(isNotableDeepSkyObject({ messier: 'M76', commonName: null, magnitude: 12 })).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps anything with a common name', () => {
|
||||
expect(isNotableDeepSkyObject({ messier: null, commonName: 'Helix Nebula', magnitude: 20 })).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an anonymous object that is bright enough', () => {
|
||||
expect(isNotableDeepSkyObject({ messier: null, commonName: null, magnitude: NOTABLE_MAGNITUDE_LIMIT })).toBe(true);
|
||||
});
|
||||
|
||||
it('drops an anonymous object fainter than the limit', () => {
|
||||
expect(isNotableDeepSkyObject({ messier: null, commonName: null, magnitude: NOTABLE_MAGNITUDE_LIMIT + 0.1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('drops an anonymous object with no measured magnitude', () => {
|
||||
// Guards the `Number('') === 0` trap: an unphotometered object must not be treated as
|
||||
// magnitude 0, which would make it brighter than every star in the sky.
|
||||
expect(isNotableDeepSkyObject({ messier: null, commonName: null, magnitude: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { DeepSkyDistanceMethod, DeepSkyKind } from '../models/deepsky.model';
|
||||
import { parallaxMasToParsecs } from './coordinates';
|
||||
|
||||
/** Speed of light in km/s (exact, by definition of the metre). */
|
||||
export const SPEED_OF_LIGHT_KM_S = 299792.458;
|
||||
|
||||
/**
|
||||
* Hubble constant in km/s/Mpc. The measured value is contested — "Hubble tension" — with
|
||||
* ~67 from the cosmic microwave background and ~73 from the local distance ladder; 70 is the
|
||||
* conventional round middle. Distances derived from it are good to roughly 10%, which is far
|
||||
* inside the tolerance needed to place a smudge of light on a backdrop.
|
||||
*/
|
||||
export const HUBBLE_CONSTANT_KM_S_PER_MPC = 70;
|
||||
|
||||
const PARSECS_PER_MEGAPARSEC = 1e6;
|
||||
|
||||
/**
|
||||
* Minimum redshift trusted for a Hubble-law distance, ≈900 km/s of recession or ~13 Mpc.
|
||||
*
|
||||
* Below this a galaxy's measured velocity is mostly its own motion through its group rather
|
||||
* than cosmological expansion, so `cz / H0` stops being a distance at all. Galaxies have
|
||||
* peculiar velocities of a few hundred km/s in any direction: the Local Group's members are
|
||||
* blueshifted outright (M31 approaches at ~300 km/s), and the Small Magellanic Cloud's small
|
||||
* positive redshift yields 2 Mpc against a true distance of 62 kpc — a 33-fold error.
|
||||
*
|
||||
* Cutting here trades coverage for honesty. Nearby galaxies come back with a `null` distance
|
||||
* instead of a confident wrong one, which is the right answer to show a user.
|
||||
*/
|
||||
const MIN_USABLE_REDSHIFT = 0.003;
|
||||
|
||||
/**
|
||||
* Upper bound on a parallax-derived distance, in parsecs. The Milky Way's disc is ~30 kpc
|
||||
* across, so a parallax implying more than this is measurement noise rather than a real
|
||||
* distance to a galactic object.
|
||||
*/
|
||||
const MAX_PARALLAX_DISTANCE_PC = 100000;
|
||||
|
||||
/**
|
||||
* OpenNGC object-type codes grouped into the three kinds the backdrop distinguishes.
|
||||
* Codes not listed here (`Dup` duplicates, `NonEx` non-existent entries, plain stars `*`,
|
||||
* doubles `**`, `Nova`, `Other`) are not deep-sky objects and are dropped.
|
||||
*/
|
||||
const KIND_BY_OPENNGC_TYPE: Readonly<Record<string, DeepSkyKind>> = {
|
||||
// Galaxies, and multi-galaxy systems.
|
||||
G: 'galaxy',
|
||||
GPair: 'galaxy',
|
||||
GTrpl: 'galaxy',
|
||||
GGroup: 'galaxy',
|
||||
// Nebulae of every flavour, including supernova remnants and cluster-with-nebulosity.
|
||||
PN: 'nebula',
|
||||
HII: 'nebula',
|
||||
EmN: 'nebula',
|
||||
RfN: 'nebula',
|
||||
Neb: 'nebula',
|
||||
DrkN: 'nebula',
|
||||
SNR: 'nebula',
|
||||
'Cl+N': 'nebula',
|
||||
// Star clusters and associations.
|
||||
OCl: 'cluster',
|
||||
GCl: 'cluster',
|
||||
'*Ass': 'cluster'
|
||||
};
|
||||
|
||||
/** Maps an OpenNGC `Type` code to a backdrop kind, or `null` if it isn't a deep-sky object. */
|
||||
export function classifyOpenNgcType(type: string | undefined | null): DeepSkyKind | null {
|
||||
return KIND_BY_OPENNGC_TYPE[(type ?? '').trim()] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hubble-law distance for a cosmological redshift, in parsecs: `d = cz / H0`.
|
||||
* Returns `null` for a redshift too small (or negative) to be dominated by expansion —
|
||||
* see {@link MIN_USABLE_REDSHIFT}.
|
||||
*/
|
||||
export function redshiftToDistancePc(redshift: number | null): number | null {
|
||||
if (redshift === null || !Number.isFinite(redshift) || redshift < MIN_USABLE_REDSHIFT) {
|
||||
return null;
|
||||
}
|
||||
return ((SPEED_OF_LIGHT_KM_S * redshift) / HUBBLE_CONSTANT_KM_S_PER_MPC) * PARSECS_PER_MEGAPARSEC;
|
||||
}
|
||||
|
||||
export interface DeepSkyDistanceEstimate {
|
||||
distancePc: number;
|
||||
method: DeepSkyDistanceMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort distance for a deep-sky object, with its provenance.
|
||||
*
|
||||
* Parallax is preferred for galactic objects (clusters, nebulae) where it is a direct
|
||||
* geometric measurement, but is *rejected outright for galaxies*: OpenNGC's parallax column
|
||||
* for a galaxy comes from a cross-matched foreground star, not the galaxy itself, and taking
|
||||
* it at face value is badly wrong — M31 lists 6 mas, implying 167 pc for something actually
|
||||
* ~780,000 pc away. Redshift is the fallback, and the only usable option for distant galaxies.
|
||||
*
|
||||
* Returns `null` when neither source is trustworthy, which is the honest answer for Local
|
||||
* Group members and for anything OpenNGC leaves unmeasured.
|
||||
*/
|
||||
export function estimateDeepSkyDistancePc(input: {
|
||||
kind: DeepSkyKind;
|
||||
redshift: number | null;
|
||||
parallaxMas: number | null;
|
||||
}): DeepSkyDistanceEstimate | null {
|
||||
const { kind, redshift, parallaxMas } = input;
|
||||
|
||||
if (kind !== 'galaxy' && parallaxMas !== null && Number.isFinite(parallaxMas) && parallaxMas > 0) {
|
||||
const distancePc = parallaxMasToParsecs(parallaxMas);
|
||||
if (Number.isFinite(distancePc) && distancePc <= MAX_PARALLAX_DISTANCE_PC) {
|
||||
return { distancePc, method: 'parallax' };
|
||||
}
|
||||
}
|
||||
|
||||
const fromRedshift = redshiftToDistancePc(redshift);
|
||||
return fromRedshift === null ? null : { distancePc: fromRedshift, method: 'redshift' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an object is notable enough for the backdrop. The full catalog is ~12,000 objects,
|
||||
* almost all of them faint anonymous galaxies that would render as visual noise; this keeps
|
||||
* the ones a person could actually pick out — everything in the Messier catalog, everything
|
||||
* with a common name, and anything else brighter than {@link NOTABLE_MAGNITUDE_LIMIT}.
|
||||
*/
|
||||
export const NOTABLE_MAGNITUDE_LIMIT = 9;
|
||||
|
||||
export function isNotableDeepSkyObject(input: {
|
||||
messier: string | null;
|
||||
commonName: string | null;
|
||||
magnitude: number | null;
|
||||
}): boolean {
|
||||
if (input.messier || input.commonName) {
|
||||
return true;
|
||||
}
|
||||
return input.magnitude !== null && input.magnitude <= NOTABLE_MAGNITUDE_LIMIT;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CartesianCoordinates } from './coordinates';
|
||||
import {
|
||||
armRadiusPc,
|
||||
BAR_HALF_LENGTH_PC,
|
||||
DISC_RADIUS_PC,
|
||||
equatorialToGalactic,
|
||||
GALACTIC_BASIS_EQUATORIAL,
|
||||
GALACTIC_LANDMARKS,
|
||||
galacticCentrePositionPc,
|
||||
galacticToEquatorial,
|
||||
galactocentricToHeliocentricGalactic,
|
||||
landmarkPositionPc,
|
||||
MILKY_WAY_ARMS,
|
||||
ORION_SPUR,
|
||||
SUN_GALACTOCENTRIC_RADIUS_PC,
|
||||
SUN_HEIGHT_ABOVE_MIDPLANE_PC
|
||||
} from './galaxy';
|
||||
|
||||
const RAD_TO_DEG = 180 / Math.PI;
|
||||
|
||||
function dot(a: CartesianCoordinates, b: CartesianCoordinates): number {
|
||||
return a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
}
|
||||
|
||||
function length(v: CartesianCoordinates): number {
|
||||
return Math.hypot(v.x, v.y, v.z);
|
||||
}
|
||||
|
||||
/** Galactic longitude of a heliocentric galactic vector, in degrees, 0-360. */
|
||||
function galacticLongitudeDeg(v: CartesianCoordinates): number {
|
||||
return (Math.atan2(v.y, v.x) * RAD_TO_DEG + 360) % 360;
|
||||
}
|
||||
|
||||
describe('GALACTIC_BASIS_EQUATORIAL', () => {
|
||||
it('is an orthonormal basis', () => {
|
||||
const { x, y, z } = GALACTIC_BASIS_EQUATORIAL;
|
||||
for (const axis of [x, y, z]) {
|
||||
expect(length(axis)).toBeCloseTo(1, 12);
|
||||
}
|
||||
expect(dot(x, y)).toBeCloseTo(0, 12);
|
||||
expect(dot(y, z)).toBeCloseTo(0, 12);
|
||||
expect(dot(z, x)).toBeCloseTo(0, 12);
|
||||
});
|
||||
|
||||
it('is right-handed, so the model is rotated rather than mirrored', () => {
|
||||
const { x, y, z } = GALACTIC_BASIS_EQUATORIAL;
|
||||
const cross = { x: x.y * y.z - x.z * y.y, y: x.z * y.x - x.x * y.z, z: x.x * y.y - x.y * y.x };
|
||||
expect(cross.x).toBeCloseTo(z.x, 12);
|
||||
expect(cross.y).toBeCloseTo(z.y, 12);
|
||||
expect(cross.z).toBeCloseTo(z.z, 12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('galacticToEquatorial', () => {
|
||||
it('is inverted exactly by equatorialToGalactic', () => {
|
||||
for (const point of [
|
||||
{ x: 1, y: 0, z: 0 },
|
||||
{ x: 0, y: 0, z: 1 },
|
||||
{ x: -8178, y: 4200, z: -75 }
|
||||
]) {
|
||||
const round = equatorialToGalactic(galacticToEquatorial(point));
|
||||
expect(round.x).toBeCloseTo(point.x, 8);
|
||||
expect(round.y).toBeCloseTo(point.y, 8);
|
||||
expect(round.z).toBeCloseTo(point.z, 8);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves length, being a rotation', () => {
|
||||
const rotated = galacticToEquatorial({ x: 300, y: -450, z: 120 });
|
||||
expect(length(rotated)).toBeCloseTo(length({ x: 300, y: -450, z: 120 }), 9);
|
||||
});
|
||||
|
||||
it('sends the galactic pole to the catalogued equatorial direction of the pole', () => {
|
||||
// Dec of the north galactic pole is +27.12825 degrees, so its equatorial z is sin of that.
|
||||
const pole = galacticToEquatorial({ x: 0, y: 0, z: 1 });
|
||||
expect(Math.asin(pole.z) * RAD_TO_DEG).toBeCloseTo(27.12825, 6);
|
||||
});
|
||||
|
||||
it('puts the galactic plane at about 60 degrees to the celestial equator', () => {
|
||||
// The complement of the pole's declination: the two planes are as far apart as their poles.
|
||||
const pole = galacticToEquatorial({ x: 0, y: 0, z: 1 });
|
||||
expect(90 - Math.asin(pole.z) * RAD_TO_DEG).toBeCloseTo(62.87, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('galactocentricToHeliocentricGalactic', () => {
|
||||
it('places the Sun at the origin, at its own radius and zero azimuth', () => {
|
||||
const sun = galactocentricToHeliocentricGalactic(SUN_GALACTOCENTRIC_RADIUS_PC, 0, SUN_HEIGHT_ABOVE_MIDPLANE_PC);
|
||||
expect(sun.x).toBeCloseTo(0, 9);
|
||||
expect(sun.y).toBeCloseTo(0, 9);
|
||||
expect(sun.z).toBeCloseTo(0, 9);
|
||||
});
|
||||
|
||||
it('puts the midplane below the Sun, not through it', () => {
|
||||
const belowSun = galactocentricToHeliocentricGalactic(SUN_GALACTOCENTRIC_RADIUS_PC, 0, 0);
|
||||
expect(belowSun.z).toBeCloseTo(-SUN_HEIGHT_ABOVE_MIDPLANE_PC, 9);
|
||||
});
|
||||
|
||||
it('places the galactic centre toward longitude zero at the Sun-centre distance', () => {
|
||||
const centre = galactocentricToHeliocentricGalactic(0, 0, 0);
|
||||
expect(galacticLongitudeDeg(centre)).toBeCloseTo(0, 6);
|
||||
expect(Math.hypot(centre.x, centre.y)).toBeCloseTo(SUN_GALACTOCENTRIC_RADIUS_PC, 6);
|
||||
});
|
||||
|
||||
it('sends increasing azimuth toward longitude 90, the direction of rotation', () => {
|
||||
const ahead = galactocentricToHeliocentricGalactic(SUN_GALACTOCENTRIC_RADIUS_PC, 30, 0);
|
||||
expect(ahead.y).toBeGreaterThan(0);
|
||||
expect(galacticLongitudeDeg(ahead)).toBeGreaterThan(0);
|
||||
expect(galacticLongitudeDeg(ahead)).toBeLessThan(180);
|
||||
});
|
||||
});
|
||||
|
||||
describe('galacticCentrePositionPc', () => {
|
||||
it('is the Sun-centre distance away, in the equatorial frame', () => {
|
||||
expect(length(galacticCentrePositionPc())).toBeCloseTo(Math.hypot(SUN_GALACTOCENTRIC_RADIUS_PC, SUN_HEIGHT_ABOVE_MIDPLANE_PC), 6);
|
||||
});
|
||||
|
||||
it('lands within a tenth of a degree of the catalogued direction of Sagittarius A*', () => {
|
||||
// The two are not identical by construction: galactic latitude zero is defined by the disc
|
||||
// the Sun orbits in, and Sgr A* sits a few hundredths of a degree off it.
|
||||
const centre = galacticCentrePositionPc();
|
||||
const catalogued = galacticToEquatorial({ x: 1, y: 0, z: 0 });
|
||||
const cosAngle = dot(centre, catalogued) / length(centre);
|
||||
expect(Math.acos(cosAngle) * RAD_TO_DEG).toBeLessThan(0.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('armRadiusPc', () => {
|
||||
it('returns the reference radius at the reference azimuth', () => {
|
||||
for (const arm of MILKY_WAY_ARMS) {
|
||||
expect(armRadiusPc(arm, arm.referenceAzimuthDeg)).toBeCloseTo(arm.referenceRadiusPc, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('winds inward as azimuth increases, so the arms trail', () => {
|
||||
for (const arm of MILKY_WAY_ARMS) {
|
||||
expect(armRadiusPc(arm, arm.referenceAzimuthDeg + 40)).toBeLessThan(arm.referenceRadiusPc);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every arm inside the modelled disc over its traced range', () => {
|
||||
for (const arm of [...MILKY_WAY_ARMS, ORION_SPUR]) {
|
||||
expect(armRadiusPc(arm, arm.fromAzimuthDeg)).toBeLessThanOrEqual(DISC_RADIUS_PC);
|
||||
expect(armRadiusPc(arm, arm.toAzimuthDeg)).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every arm clear of the bar, so the spiral starts where the bar ends', () => {
|
||||
for (const arm of MILKY_WAY_ARMS) {
|
||||
for (let beta = arm.fromAzimuthDeg; beta <= arm.toAzimuthDeg; beta += 10) {
|
||||
expect(armRadiusPc(arm, beta)).toBeGreaterThan(0);
|
||||
}
|
||||
expect(arm.referenceRadiusPc).toBeGreaterThan(BAR_HALF_LENGTH_PC * 0.9);
|
||||
}
|
||||
});
|
||||
|
||||
it('brackets the Sun between the Sagittarius and Perseus arms at the Sun azimuth', () => {
|
||||
// The one arrangement the local star field depends on: the solar neighbourhood sits in the
|
||||
// gap between them, on the minor spur, not inside a major arm.
|
||||
const sagittarius = MILKY_WAY_ARMS.find((arm) => arm.name.startsWith('Sagittarius'))!;
|
||||
const perseus = MILKY_WAY_ARMS.find((arm) => arm.name === 'Perseus')!;
|
||||
|
||||
expect(armRadiusPc(sagittarius, 0)).toBeLessThan(SUN_GALACTOCENTRIC_RADIUS_PC);
|
||||
expect(armRadiusPc(perseus, 0)).toBeGreaterThan(SUN_GALACTOCENTRIC_RADIUS_PC);
|
||||
});
|
||||
|
||||
it('runs the Orion Spur past the Sun, close to the Sun radius', () => {
|
||||
expect(Math.abs(armRadiusPc(ORION_SPUR, 0) - SUN_GALACTOCENTRIC_RADIUS_PC)).toBeLessThan(600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GALACTIC_LANDMARKS', () => {
|
||||
it('has a unique id per landmark', () => {
|
||||
const ids = GALACTIC_LANDMARKS.map((landmark) => landmark.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('names one landmark per modelled arm, plus the centre, the Sun and the spur', () => {
|
||||
expect(GALACTIC_LANDMARKS).toHaveLength(MILKY_WAY_ARMS.length + 3);
|
||||
});
|
||||
|
||||
it('puts Sol back at the origin', () => {
|
||||
const sol = GALACTIC_LANDMARKS.find((landmark) => landmark.id === 'sol')!;
|
||||
const position = landmarkPositionPc(sol);
|
||||
// The Sun is the origin of the scene's coordinates, give or take its height above the plane,
|
||||
// which the landmark deliberately drops so the label sits on the map rather than off it.
|
||||
expect(Math.hypot(position.x, position.y, position.z)).toBeCloseTo(SUN_HEIGHT_ABOVE_MIDPLANE_PC, 6);
|
||||
});
|
||||
|
||||
it('keeps every landmark inside the modelled disc', () => {
|
||||
for (const landmark of GALACTIC_LANDMARKS) {
|
||||
expect(length(landmarkPositionPc(landmark))).toBeLessThan(DISC_RADIUS_PC * 2);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { CartesianCoordinates, raDegDecDistanceToXyz } from './coordinates';
|
||||
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
|
||||
/**
|
||||
* Structural model of the Milky Way, and the rotation that carries it into the equatorial
|
||||
* frame the rest of the scene works in.
|
||||
*
|
||||
* **This is a model, not a catalogue.** Every other dataset in this app is measured: HYG gives
|
||||
* real parallaxes, Horizons real ephemerides, the Exoplanet Archive real orbits. The Galaxy is
|
||||
* different — we sit inside it, and dust blocks the view across the disc, so no catalogue holds
|
||||
* the positions of its stars. What *is* measured is its skeleton: the distance to the centre,
|
||||
* the tilt of the disc against the sky, and the radius/pitch/azimuth of each spiral arm from
|
||||
* maser parallaxes. Those measurements are the constants below; the individual particles the
|
||||
* renderer scatters around them are illustrative, and labelled as such in the UI.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Direction of the galactic centre (Sgr A*) and the north galactic pole, in equatorial J2000.
|
||||
* These two directions are what tie the model to the sky: everything else is built in galactic
|
||||
* coordinates and rotated through them.
|
||||
*/
|
||||
export const GALACTIC_CENTRE_RA_DEG = 266.4051;
|
||||
export const GALACTIC_CENTRE_DEC_DEG = -28.936175;
|
||||
export const NORTH_GALACTIC_POLE_RA_DEG = 192.85948;
|
||||
export const NORTH_GALACTIC_POLE_DEC_DEG = 27.12825;
|
||||
|
||||
/**
|
||||
* Sun-to-galactic-centre distance, in parsecs (GRAVITY Collaboration 2019, from the orbit of
|
||||
* S2 around Sgr A*), and the Sun's height above the disc midplane.
|
||||
*/
|
||||
export const SUN_GALACTOCENTRIC_RADIUS_PC = 8178;
|
||||
export const SUN_HEIGHT_ABOVE_MIDPLANE_PC = 20.8;
|
||||
|
||||
/** Rough visible extent of the stellar disc, and its exponential scale length/height. */
|
||||
export const DISC_RADIUS_PC = 16000;
|
||||
export const DISC_SCALE_LENGTH_PC = 2600;
|
||||
export const DISC_SCALE_HEIGHT_PC = 300;
|
||||
|
||||
/** Boxy/peanut bulge and bar: half-length, half-width, half-thickness, and orientation. */
|
||||
export const BAR_HALF_LENGTH_PC = 4200;
|
||||
export const BAR_HALF_WIDTH_PC = 1300;
|
||||
export const BAR_HALF_THICKNESS_PC = 900;
|
||||
/** Angle between the bar's long axis and the Sun-centre line, near end at positive longitude. */
|
||||
export const BAR_POSITION_ANGLE_DEG = 25;
|
||||
|
||||
/**
|
||||
* A spiral arm as a logarithmic spiral: `R(beta) = referenceRadiusPc * exp(-(beta -
|
||||
* referenceAzimuthDeg) * tan(pitchAngleDeg))`.
|
||||
*
|
||||
* `beta` is the galactocentric azimuth measured from the Sun's direction, increasing in the
|
||||
* direction of galactic rotation — the convention used by the maser-parallax surveys these
|
||||
* figures approximate (Reid et al. 2019). Values are rounded; they place each arm on the right
|
||||
* side of the Sun at the right pitch, which is what the view needs, and are not a substitute
|
||||
* for the published fits.
|
||||
*/
|
||||
export interface SpiralArm {
|
||||
readonly name: string;
|
||||
readonly referenceRadiusPc: number;
|
||||
readonly referenceAzimuthDeg: number;
|
||||
readonly pitchAngleDeg: number;
|
||||
/** Azimuth range to trace the arm over, in the same `beta` convention. */
|
||||
readonly fromAzimuthDeg: number;
|
||||
readonly toAzimuthDeg: number;
|
||||
/**
|
||||
* Where along the arm to anchor its name. Staggered between arms on purpose: anchoring them
|
||||
* all at one azimuth stacks five labels on the same radial line, which is unreadable.
|
||||
*/
|
||||
readonly labelAzimuthDeg: number;
|
||||
/** Half-width of the star-forming ridge, in parsecs — how far particles scatter off the spine. */
|
||||
readonly widthPc: number;
|
||||
/** Relative particle density, so the two grand-design arms read as the dominant pair. */
|
||||
readonly weight: number;
|
||||
}
|
||||
|
||||
export const MILKY_WAY_ARMS: readonly SpiralArm[] = [
|
||||
{ name: 'Norma', referenceRadiusPc: 4460, referenceAzimuthDeg: 18, pitchAngleDeg: 1, fromAzimuthDeg: -20, toAzimuthDeg: 200, labelAzimuthDeg: 120, widthPc: 500, weight: 0.7 },
|
||||
{ name: 'Scutum–Centaurus', referenceRadiusPc: 4910, referenceAzimuthDeg: 23, pitchAngleDeg: 12.1, fromAzimuthDeg: -30, toAzimuthDeg: 290, labelAzimuthDeg: 70, widthPc: 700, weight: 1 },
|
||||
{ name: 'Sagittarius–Carina', referenceRadiusPc: 6040, referenceAzimuthDeg: 24, pitchAngleDeg: 17.1, fromAzimuthDeg: -40, toAzimuthDeg: 230, labelAzimuthDeg: -20, widthPc: 650, weight: 0.95 },
|
||||
{ name: 'Perseus', referenceRadiusPc: 8870, referenceAzimuthDeg: 40, pitchAngleDeg: 10.3, fromAzimuthDeg: -60, toAzimuthDeg: 220, labelAzimuthDeg: 90, widthPc: 700, weight: 1 },
|
||||
{ name: 'Outer', referenceRadiusPc: 12240, referenceAzimuthDeg: 18, pitchAngleDeg: 3, fromAzimuthDeg: -60, toAzimuthDeg: 200, labelAzimuthDeg: 30, widthPc: 800, weight: 0.55 }
|
||||
];
|
||||
|
||||
/**
|
||||
* The Orion Spur — the minor arm the Sun sits in, which is why the local star field is not
|
||||
* empty. Short, so it is described by its own azimuth span rather than the full sweep above.
|
||||
*/
|
||||
export const ORION_SPUR: SpiralArm = {
|
||||
name: 'Orion Spur',
|
||||
referenceRadiusPc: 8260,
|
||||
referenceAzimuthDeg: 8.9,
|
||||
pitchAngleDeg: 11.4,
|
||||
fromAzimuthDeg: -25,
|
||||
toAzimuthDeg: 45,
|
||||
labelAzimuthDeg: 20,
|
||||
widthPc: 400,
|
||||
weight: 0.45
|
||||
};
|
||||
|
||||
/**
|
||||
* Galactocentric radius of a point on an arm at azimuth `betaDeg`, in parsecs.
|
||||
* Diverges for large negative azimuths by construction — a logarithmic spiral has no
|
||||
* outer end — so callers trace it only over the arm's own azimuth range.
|
||||
*/
|
||||
export function armRadiusPc(arm: SpiralArm, betaDeg: number): number {
|
||||
return arm.referenceRadiusPc * Math.exp(-(betaDeg - arm.referenceAzimuthDeg) * DEG_TO_RAD * Math.tan(arm.pitchAngleDeg * DEG_TO_RAD));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts galactocentric cylindrical coordinates into heliocentric galactic Cartesian
|
||||
* coordinates in parsecs: +X toward the galactic centre, +Y toward galactic longitude 90
|
||||
* (the direction of the Sun's rotation about the centre), +Z toward the north galactic pole.
|
||||
*
|
||||
* `heightPc` is measured from the disc midplane, not from the Sun — so a point with
|
||||
* `heightPc = 0` comes out at `z = -SUN_HEIGHT_ABOVE_MIDPLANE_PC`, since the Sun sits a little
|
||||
* above the plane it orbits in.
|
||||
*/
|
||||
export function galactocentricToHeliocentricGalactic(radiusPc: number, azimuthDeg: number, heightPc: number): CartesianCoordinates {
|
||||
const beta = azimuthDeg * DEG_TO_RAD;
|
||||
return {
|
||||
x: SUN_GALACTOCENTRIC_RADIUS_PC - radiusPc * Math.cos(beta),
|
||||
y: radiusPc * Math.sin(beta),
|
||||
z: heightPc - SUN_HEIGHT_ABOVE_MIDPLANE_PC
|
||||
};
|
||||
}
|
||||
|
||||
function normalize(v: CartesianCoordinates): CartesianCoordinates {
|
||||
const length = Math.hypot(v.x, v.y, v.z);
|
||||
return { x: v.x / length, y: v.y / length, z: v.z / length };
|
||||
}
|
||||
|
||||
function cross(a: CartesianCoordinates, b: CartesianCoordinates): CartesianCoordinates {
|
||||
return { x: a.y * b.z - a.z * b.y, y: a.z * b.x - a.x * b.z, z: a.x * b.y - a.y * b.x };
|
||||
}
|
||||
|
||||
/**
|
||||
* The galactic frame's basis vectors, expressed in the equatorial frame.
|
||||
*
|
||||
* Built from the two catalogue directions above, which are given to finite precision and so are
|
||||
* not exactly perpendicular. The pole is taken as authoritative for "up" and the centre
|
||||
* direction is re-orthogonalised against it, which keeps the result an exact rotation — a basis
|
||||
* that is a fraction of an arcsecond off square would shear the whole model.
|
||||
*/
|
||||
const GALACTIC_POLE_EQUATORIAL = raDegDecDistanceToXyz(NORTH_GALACTIC_POLE_RA_DEG, NORTH_GALACTIC_POLE_DEC_DEG, 1);
|
||||
const GALACTIC_CENTRE_EQUATORIAL = raDegDecDistanceToXyz(GALACTIC_CENTRE_RA_DEG, GALACTIC_CENTRE_DEC_DEG, 1);
|
||||
const GALACTIC_Z = normalize(GALACTIC_POLE_EQUATORIAL);
|
||||
const GALACTIC_Y = normalize(cross(GALACTIC_Z, GALACTIC_CENTRE_EQUATORIAL));
|
||||
const GALACTIC_X = cross(GALACTIC_Y, GALACTIC_Z);
|
||||
|
||||
export const GALACTIC_BASIS_EQUATORIAL = {
|
||||
x: GALACTIC_X,
|
||||
y: GALACTIC_Y,
|
||||
z: GALACTIC_Z
|
||||
} as const;
|
||||
|
||||
/** Rotates a heliocentric galactic vector into the scene's equatorial frame. */
|
||||
export function galacticToEquatorial(v: CartesianCoordinates): CartesianCoordinates {
|
||||
return {
|
||||
x: GALACTIC_X.x * v.x + GALACTIC_Y.x * v.y + GALACTIC_Z.x * v.z,
|
||||
y: GALACTIC_X.y * v.x + GALACTIC_Y.y * v.y + GALACTIC_Z.y * v.z,
|
||||
z: GALACTIC_X.z * v.x + GALACTIC_Y.z * v.y + GALACTIC_Z.z * v.z
|
||||
};
|
||||
}
|
||||
|
||||
/** Rotates an equatorial vector into heliocentric galactic coordinates (the inverse rotation). */
|
||||
export function equatorialToGalactic(v: CartesianCoordinates): CartesianCoordinates {
|
||||
return {
|
||||
x: GALACTIC_X.x * v.x + GALACTIC_X.y * v.y + GALACTIC_X.z * v.z,
|
||||
y: GALACTIC_Y.x * v.x + GALACTIC_Y.y * v.y + GALACTIC_Y.z * v.z,
|
||||
z: GALACTIC_Z.x * v.x + GALACTIC_Z.y * v.y + GALACTIC_Z.z * v.z
|
||||
};
|
||||
}
|
||||
|
||||
/** The galactic centre's position in the scene's equatorial frame, in parsecs from the Sun. */
|
||||
export function galacticCentrePositionPc(): CartesianCoordinates {
|
||||
return galacticToEquatorial(galactocentricToHeliocentricGalactic(0, 0, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Named landmarks worth pinning in the galactic view. Positions are derived from the same
|
||||
* structural constants as the model, so a label always sits on the feature it names.
|
||||
*/
|
||||
export interface GalacticLandmark {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
/** What the landmark is, printed under its name on the map. */
|
||||
readonly kind: string;
|
||||
/** Galactocentric radius/azimuth, matching {@link galactocentricToHeliocentricGalactic}. */
|
||||
readonly radiusPc: number;
|
||||
readonly azimuthDeg: number;
|
||||
}
|
||||
|
||||
export const GALACTIC_LANDMARKS: readonly GalacticLandmark[] = [
|
||||
{ id: 'sgr-a', name: 'Sagittarius A*', kind: 'Galactic Centre', radiusPc: 0, azimuthDeg: 0 },
|
||||
{ id: 'sol', name: 'Sol', kind: 'Star', radiusPc: SUN_GALACTOCENTRIC_RADIUS_PC, azimuthDeg: 0 },
|
||||
...MILKY_WAY_ARMS.map((arm) => ({
|
||||
id: `arm-${arm.name.toLowerCase().replace(/[^a-z]+/g, '-')}`,
|
||||
name: `${arm.name} Arm`,
|
||||
kind: 'Spiral Arm',
|
||||
radiusPc: armRadiusPc(arm, arm.labelAzimuthDeg),
|
||||
azimuthDeg: arm.labelAzimuthDeg
|
||||
})),
|
||||
{
|
||||
id: 'arm-orion-spur',
|
||||
name: 'Orion Spur',
|
||||
kind: 'Spur',
|
||||
radiusPc: armRadiusPc(ORION_SPUR, ORION_SPUR.labelAzimuthDeg),
|
||||
azimuthDeg: ORION_SPUR.labelAzimuthDeg
|
||||
}
|
||||
];
|
||||
|
||||
/** A landmark's position in the scene's equatorial frame, in parsecs from the Sun. */
|
||||
export function landmarkPositionPc(landmark: GalacticLandmark): CartesianCoordinates {
|
||||
return galacticToEquatorial(galactocentricToHeliocentricGalactic(landmark.radiusPc, landmark.azimuthDeg, 0));
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { StarRecord } from '../models/star.model';
|
||||
// A small fixture standing in for a slice of the HYG star index, used to exercise the
|
||||
// exoplanet host-star cross-referencing logic without hitting any real API.
|
||||
const FIXTURE_STARS: StarRecord[] = [
|
||||
// The Sun sits at the origin, exactly where a host with a missing distance lands.
|
||||
{ id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 },
|
||||
{ id: 1, name: 'Proxima Centauri', x: -0.472264, y: -0.361451, z: -1.151219, magnitude: 11.01, spectralType: 'M5Ve', colorIndex: 1.807 },
|
||||
{ id: 2, name: 'Sirius', x: -0.494323, y: 2.476731, z: -0.758485, magnitude: -1.44, spectralType: 'A0m...', colorIndex: 0.009 },
|
||||
{ id: 3, name: 'GJ 3512', x: 3.0, y: 4.0, z: 5.0, magnitude: 11.0, spectralType: 'M5.5', colorIndex: 1.6 }
|
||||
@@ -61,4 +63,34 @@ describe('resolveHostStarId', () => {
|
||||
|
||||
expect(id).toBe(10);
|
||||
});
|
||||
|
||||
describe('missing distance column', () => {
|
||||
// The Exoplanet Archive leaves `sy_dist` blank for some systems. `Number('')` is `0` —
|
||||
// finite, so it slips past a naive guard — which puts the host at the origin and matches
|
||||
// the Sun at distance 0. That shipped 127 alien planets, all seven TRAPPIST-1 worlds among
|
||||
// them, into our own solar system.
|
||||
it('does not match a host with a zero distance to the Sun', () => {
|
||||
const id = resolveHostStarId({ hostname: 'TRAPPIST-1', raDeg: 346.6, decDeg: -5.04, distancePc: 0 }, FIXTURE_STARS, 0.5);
|
||||
|
||||
expect(id).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a negative distance too', () => {
|
||||
const id = resolveHostStarId({ hostname: 'Nowhere', raDeg: 10, decDeg: 10, distancePc: -3 }, FIXTURE_STARS, 0.5);
|
||||
|
||||
expect(id).toBeNull();
|
||||
});
|
||||
|
||||
it('still matches a real host at a genuinely small distance', () => {
|
||||
const id = resolveHostStarId({ hostname: 'Unmatched', raDeg: 217.4, decDeg: -62.68, distancePc: 1.2959 }, FIXTURE_STARS, 0.5);
|
||||
|
||||
expect(id).toBe(1);
|
||||
});
|
||||
|
||||
it('lets a named host resolve even with no usable distance', () => {
|
||||
const id = resolveHostStarId({ hostname: 'Sirius', raDeg: 101.3, decDeg: -16.7, distancePc: 0 }, FIXTURE_STARS, 0.5);
|
||||
|
||||
expect(id).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CartesianCoordinates, distanceBetween, raDegDecDistanceToXyz } from './coordinates';
|
||||
import { ExoplanetRecord } from '../models/exoplanet.model';
|
||||
import { StarRecord } from '../models/star.model';
|
||||
|
||||
/** Normalizes a star name for comparison: lowercase, alphanumeric characters only. */
|
||||
@@ -41,6 +42,14 @@ export function resolveHostStarId(
|
||||
return null;
|
||||
}
|
||||
|
||||
// A non-positive distance is never a real measurement, and it is the specific shape a
|
||||
// missing CSV cell takes: `Number('')` is `0`, which passes the finiteness check above and
|
||||
// then places the host exactly at the origin — where it matches the Sun at distance 0 and
|
||||
// hands an alien planet to our own solar system.
|
||||
if (query.distancePc <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostPosition = raDegDecDistanceToXyz(query.raDeg, query.decDeg, query.distancePc);
|
||||
return findNearestStarWithin(hostPosition, stars, toleranceInPc);
|
||||
}
|
||||
@@ -57,3 +66,68 @@ function findNearestStarWithin(position: CartesianCoordinates, stars: readonly S
|
||||
|
||||
return closest ? closest.id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-resolves every exoplanet's host star against a star catalogue.
|
||||
*
|
||||
* The cross-reference is a *derived* fact: it depends as much on which stars were loaded as on
|
||||
* the archive itself. When the catalogue reached 50 pc, 388 of the archive's 4735 named hosts
|
||||
* found a match and the other 4347 were carried and never drawn — not because their planets are
|
||||
* unknown, but because their star was out of range. Widening the catalogue rescues some of them,
|
||||
* and until the host coordinates were stored alongside each planet that meant re-downloading an
|
||||
* archive which is not always reachable.
|
||||
*
|
||||
* Records written before those coordinates were kept can still be matched *by name*, which needs
|
||||
* no coordinates at all — and that alone is worth doing, because a wider catalogue contains more
|
||||
* names. What such a record cannot do is disprove its existing match: a name miss means only
|
||||
* that the name missed, not that the star is absent. So those are upgraded where a match is
|
||||
* found and left alone otherwise, while records that do carry coordinates take the new result
|
||||
* outright, match or no match.
|
||||
*/
|
||||
|
||||
/** A host must sit within this many parsecs of a catalogue star to count as the same object. */
|
||||
export const HOST_MATCH_TOLERANCE_PC = 2;
|
||||
|
||||
export interface RematchSummary {
|
||||
total: number;
|
||||
/** Records carrying host coordinates, and therefore eligible to be re-matched in full. */
|
||||
resolvable: number;
|
||||
matched: number;
|
||||
gained: number;
|
||||
lost: number;
|
||||
}
|
||||
|
||||
export function rematchHostStars(exoplanets: ExoplanetRecord[], stars: readonly StarRecord[]): RematchSummary {
|
||||
const nameIndex = buildStarNameIndex(stars);
|
||||
const summary: RematchSummary = { total: exoplanets.length, resolvable: 0, matched: 0, gained: 0, lost: 0 };
|
||||
|
||||
for (const exoplanet of exoplanets) {
|
||||
const { hostRaDeg, hostDecDeg, hostDistancePc } = exoplanet;
|
||||
const positioned = hostRaDeg !== undefined && hostDecDeg !== undefined && hostDistancePc !== undefined;
|
||||
if (positioned) {
|
||||
summary.resolvable++;
|
||||
}
|
||||
|
||||
const previous = exoplanet.hostStarId;
|
||||
// With no coordinates the query still carries the host's name, and `resolveHostStarId` tries
|
||||
// that first; the positional fallback simply declines to run on non-finite coordinates.
|
||||
const resolved = resolveHostStarId(
|
||||
{ hostname: exoplanet.hostStarName, raDeg: hostRaDeg ?? Number.NaN, decDeg: hostDecDeg ?? Number.NaN, distancePc: hostDistancePc ?? Number.NaN },
|
||||
stars,
|
||||
HOST_MATCH_TOLERANCE_PC,
|
||||
nameIndex
|
||||
);
|
||||
|
||||
exoplanet.hostStarId = positioned ? resolved : (resolved ?? previous);
|
||||
if (exoplanet.hostStarId !== null) {
|
||||
summary.matched++;
|
||||
}
|
||||
if (previous === null && exoplanet.hostStarId !== null) {
|
||||
summary.gained++;
|
||||
} else if (previous !== null && exoplanet.hostStarId === null) {
|
||||
summary.lost++;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ExoplanetRecord } from '../models/exoplanet.model';
|
||||
import { StarRecord } from '../models/star.model';
|
||||
import { rematchHostStars } from './host-star-matching';
|
||||
|
||||
/** Two catalogue stars, one of which is only present in the wider of the two catalogues. */
|
||||
const NEARBY: StarRecord = { id: 100, name: 'Gl 357', x: 9, y: 0, z: 0, magnitude: 10.9, spectralType: 'K', colorIndex: 1.4 };
|
||||
const DISTANT: StarRecord = { id: 200, name: 'HD 33844', x: 0, y: 120, z: 0, magnitude: 7.7, spectralType: 'K0', colorIndex: 1.0 };
|
||||
|
||||
const NARROW_CATALOGUE = [NEARBY];
|
||||
const WIDE_CATALOGUE = [NEARBY, DISTANT];
|
||||
|
||||
function planet(overrides: Partial<ExoplanetRecord> = {}): ExoplanetRecord {
|
||||
return { id: 'p', hostStarId: null, hostStarName: 'HD 33844', name: 'HD 33844 b', orbit: { semiMajorAxisAu: 1 }, ...overrides };
|
||||
}
|
||||
|
||||
describe('rematchHostStars', () => {
|
||||
it('rescues a host that the wider catalogue now contains, by name alone', () => {
|
||||
// The whole point: the cross-reference is a fact about the catalogue as much as about the
|
||||
// archive, so widening one ought to resolve hosts the other already knew about.
|
||||
const planets = [planet()];
|
||||
const summary = rematchHostStars(planets, WIDE_CATALOGUE);
|
||||
|
||||
expect(planets[0].hostStarId).toBe(DISTANT.id);
|
||||
expect(summary.gained).toBe(1);
|
||||
expect(summary.matched).toBe(1);
|
||||
});
|
||||
|
||||
it('needs no coordinates to do it', () => {
|
||||
// Which matters, because the shipped records were written before coordinates were kept.
|
||||
const planets = [planet()];
|
||||
expect(planets[0].hostRaDeg).toBeUndefined();
|
||||
rematchHostStars(planets, WIDE_CATALOGUE);
|
||||
expect(planets[0].hostStarId).toBe(DISTANT.id);
|
||||
});
|
||||
|
||||
it('will not clear an existing match on a name miss when it has no coordinates', () => {
|
||||
// A name miss says the name missed, not that the star is absent — and the earlier match may
|
||||
// have been positional, from data this record no longer carries.
|
||||
const planets = [planet({ hostStarId: 999, hostStarName: 'Some Survey Designation' })];
|
||||
const summary = rematchHostStars(planets, WIDE_CATALOGUE);
|
||||
|
||||
expect(planets[0].hostStarId).toBe(999);
|
||||
expect(summary.lost).toBe(0);
|
||||
expect(summary.matched).toBe(1);
|
||||
});
|
||||
|
||||
it('takes the new answer outright when the record does carry coordinates', () => {
|
||||
// With coordinates the match can be redone in full, so its result is authoritative — a host
|
||||
// that no longer resolves is cleared rather than left pointing at a star that may be gone.
|
||||
const planets = [planet({ hostStarId: 999, hostStarName: 'Nowhere', hostRaDeg: 10, hostDecDeg: 10, hostDistancePc: 500 })];
|
||||
const summary = rematchHostStars(planets, WIDE_CATALOGUE);
|
||||
|
||||
expect(planets[0].hostStarId).toBeNull();
|
||||
expect(summary.resolvable).toBe(1);
|
||||
expect(summary.lost).toBe(1);
|
||||
});
|
||||
|
||||
it('matches a positioned host to the catalogue star at its coordinates', () => {
|
||||
const planets = [planet({ hostStarName: 'unlisted alias', hostRaDeg: 90, hostDecDeg: 0, hostDistancePc: 120 })];
|
||||
rematchHostStars(planets, WIDE_CATALOGUE);
|
||||
expect(planets[0].hostStarId).toBe(DISTANT.id);
|
||||
});
|
||||
|
||||
it('leaves a host that neither catalogue contains unmatched', () => {
|
||||
const planets = [planet()];
|
||||
const summary = rematchHostStars(planets, NARROW_CATALOGUE);
|
||||
|
||||
expect(planets[0].hostStarId).toBeNull();
|
||||
expect(summary.matched).toBe(0);
|
||||
expect(summary.gained).toBe(0);
|
||||
});
|
||||
|
||||
it('counts every record it was given', () => {
|
||||
const planets = [planet(), planet({ id: 'q', hostStarName: 'Gl 357' }), planet({ id: 'r', hostStarName: 'nobody' })];
|
||||
const summary = rematchHostStars(planets, WIDE_CATALOGUE);
|
||||
|
||||
expect(summary.total).toBe(3);
|
||||
expect(summary.matched).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,14 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GM_SUN_AU3_PER_DAY2, DEFAULT_EPOCH_JD } from './constants';
|
||||
import {
|
||||
gravitationalParameterFromPeriod,
|
||||
isPropagatableOrbit,
|
||||
meanMotionRadPerDay,
|
||||
orbitEllipsePoints,
|
||||
orbitalPeriodDays,
|
||||
positionAtTrueAnomaly,
|
||||
propagateOrbit,
|
||||
resolveGravitationalParameter,
|
||||
resolveOrbitalElements,
|
||||
solveEccentricAnomaly,
|
||||
trueAnomalyFromEccentricAnomaly
|
||||
@@ -163,3 +166,145 @@ describe('resolveOrbitalElements', () => {
|
||||
expect(resolved.argumentOfPeriapsisDeg).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gravitationalParameterFromPeriod', () => {
|
||||
it('round-trips with orbitalPeriodDays', () => {
|
||||
const derived = gravitationalParameterFromPeriod(1, 365.256);
|
||||
expect(orbitalPeriodDays(1, derived)).toBeCloseTo(365.256, 9);
|
||||
});
|
||||
|
||||
it('recovers the Sun from Earth\'s orbit', () => {
|
||||
// 1 AU in one sidereal year is the definition of the solar gravitational parameter.
|
||||
const derived = gravitationalParameterFromPeriod(1, 365.256363);
|
||||
expect(derived / GM_SUN_AU3_PER_DAY2).toBeCloseTo(1, 4);
|
||||
});
|
||||
|
||||
it('recovers a red dwarf from a real short-period orbit', () => {
|
||||
// TRAPPIST-1 b: 0.01154 AU in 1.51088 days around a 0.0898 solar-mass star.
|
||||
const derived = gravitationalParameterFromPeriod(0.01154, 1.51088);
|
||||
expect(derived / GM_SUN_AU3_PER_DAY2).toBeCloseTo(0.09, 2);
|
||||
});
|
||||
|
||||
it('scales as a^3 at fixed period', () => {
|
||||
const single = gravitationalParameterFromPeriod(1, 100);
|
||||
const doubled = gravitationalParameterFromPeriod(2, 100);
|
||||
expect(doubled / single).toBeCloseTo(8, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGravitationalParameter', () => {
|
||||
it('prefers the measured period over everything else', () => {
|
||||
// The period says 0.09 solar masses; the (deliberately wrong) host mass says 5.
|
||||
const gm = resolveGravitationalParameter({ semiMajorAxisAu: 0.01154, periodDays: 1.51088, hostStarMassSolar: 5 });
|
||||
expect(gm / GM_SUN_AU3_PER_DAY2).toBeCloseTo(0.09, 2);
|
||||
});
|
||||
|
||||
it('corrects a red dwarf planet that the solar-mass assumption spun too fast', () => {
|
||||
const withPeriod = resolveGravitationalParameter({ semiMajorAxisAu: 0.01154, periodDays: 1.51088 });
|
||||
const assumingSolar = resolveGravitationalParameter({ semiMajorAxisAu: 0.01154 });
|
||||
|
||||
// A heavier central mass pulls harder, so it shortens the period: T scales as 1/sqrt(GM).
|
||||
// Assuming the Sun for TRAPPIST-1's 0.09 solar masses therefore made its planets orbit
|
||||
// sqrt(0.09) = 0.3x the true period — about 3.3x too fast, not too slow.
|
||||
expect(orbitalPeriodDays(0.01154, withPeriod)).toBeCloseTo(1.51088, 4);
|
||||
const ratio = orbitalPeriodDays(0.01154, assumingSolar) / orbitalPeriodDays(0.01154, withPeriod);
|
||||
expect(ratio).toBeCloseTo(Math.sqrt(0.09), 2);
|
||||
});
|
||||
|
||||
it('falls back to the host star mass when no period is published', () => {
|
||||
const gm = resolveGravitationalParameter({ semiMajorAxisAu: 0.5, hostStarMassSolar: 0.31 });
|
||||
expect(gm).toBeCloseTo(GM_SUN_AU3_PER_DAY2 * 0.31, 12);
|
||||
});
|
||||
|
||||
it('falls back to one solar mass when nothing is known', () => {
|
||||
expect(resolveGravitationalParameter({ semiMajorAxisAu: 1 })).toBe(GM_SUN_AU3_PER_DAY2);
|
||||
});
|
||||
|
||||
it('ignores a period that is missing, zero, negative or not a number', () => {
|
||||
for (const periodDays of [undefined, 0, -5, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
expect(resolveGravitationalParameter({ semiMajorAxisAu: 1, periodDays })).toBe(GM_SUN_AU3_PER_DAY2);
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores a host mass that is missing, zero or negative', () => {
|
||||
for (const hostStarMassSolar of [undefined, 0, -1, Number.NaN]) {
|
||||
expect(resolveGravitationalParameter({ semiMajorAxisAu: 1, hostStarMassSolar })).toBe(GM_SUN_AU3_PER_DAY2);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a period implying something that cannot be a star, and falls through', () => {
|
||||
// 1 AU in a single day implies thousands of solar masses.
|
||||
const gm = resolveGravitationalParameter({ semiMajorAxisAu: 1, periodDays: 1, hostStarMassSolar: 0.5 });
|
||||
expect(gm).toBeCloseTo(GM_SUN_AU3_PER_DAY2 * 0.5, 12);
|
||||
});
|
||||
|
||||
it('rejects a period implying far too little mass', () => {
|
||||
// 1 AU taking a million days implies a mass far below any star.
|
||||
expect(resolveGravitationalParameter({ semiMajorAxisAu: 1, periodDays: 1e6 })).toBe(GM_SUN_AU3_PER_DAY2);
|
||||
});
|
||||
|
||||
it('rejects an implausible host mass too', () => {
|
||||
expect(resolveGravitationalParameter({ semiMajorAxisAu: 1, hostStarMassSolar: 5000 })).toBe(GM_SUN_AU3_PER_DAY2);
|
||||
});
|
||||
|
||||
it('keeps a real short-period hot Jupiter around a sun-like star', () => {
|
||||
// 51 Pegasi b: 0.0527 AU in 4.23 days around a ~1.1 solar-mass star.
|
||||
const gm = resolveGravitationalParameter({ semiMajorAxisAu: 0.0527, periodDays: 4.230785 });
|
||||
expect(gm / GM_SUN_AU3_PER_DAY2).toBeCloseTo(1.1, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPropagatableOrbit', () => {
|
||||
it('accepts an orbit with only a semi-major axis', () => {
|
||||
// 1509 archive records publish an axis and no eccentricity; they are perfectly drawable.
|
||||
expect(isPropagatableOrbit({ semiMajorAxisAu: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a fully specified elliptical orbit', () => {
|
||||
expect(isPropagatableOrbit({ semiMajorAxisAu: 0.05, eccentricity: 0.62 })).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts the boundary eccentricities of an ellipse', () => {
|
||||
expect(isPropagatableOrbit({ semiMajorAxisAu: 1, eccentricity: 0 })).toBe(true);
|
||||
expect(isPropagatableOrbit({ semiMajorAxisAu: 1, eccentricity: 0.999 })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an orbit with no semi-major axis at all', () => {
|
||||
expect(isPropagatableOrbit({})).toBe(false);
|
||||
expect(isPropagatableOrbit({ eccentricity: 0.1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a non-positive or non-finite semi-major axis', () => {
|
||||
// sqrt of a negative and division by zero both yield NaN rather than throwing.
|
||||
for (const semiMajorAxisAu of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
expect(isPropagatableOrbit({ semiMajorAxisAu })).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an eccentricity that is not an ellipse', () => {
|
||||
// e >= 1 is a parabolic or hyperbolic escape trajectory, which no ellipse describes.
|
||||
for (const eccentricity of [1, 1.4, -0.2, Number.NaN]) {
|
||||
expect(isPropagatableOrbit({ semiMajorAxisAu: 1, eccentricity })).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOrbitalElements eccentricity default', () => {
|
||||
it('treats a missing eccentricity as a circle', () => {
|
||||
expect(resolveOrbitalElements({ semiMajorAxisAu: 2 }).eccentricity).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps a published eccentricity, including exactly zero', () => {
|
||||
expect(resolveOrbitalElements({ semiMajorAxisAu: 2, eccentricity: 0.35 }).eccentricity).toBe(0.35);
|
||||
expect(resolveOrbitalElements({ semiMajorAxisAu: 2, eccentricity: 0 }).eccentricity).toBe(0);
|
||||
});
|
||||
|
||||
it('produces a genuine circle, not a degenerate ellipse', () => {
|
||||
const elements = resolveOrbitalElements({ semiMajorAxisAu: 2 });
|
||||
const radii = orbitEllipsePoints(elements).map((point) => Math.hypot(point.x, point.y, point.z));
|
||||
|
||||
for (const radius of radii) {
|
||||
expect(radius).toBeCloseTo(2, 9);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CartesianCoordinates } from './coordinates';
|
||||
import { DEFAULT_EPOCH_JD } from './constants';
|
||||
import { DEFAULT_EPOCH_JD, GM_SUN_AU3_PER_DAY2 } from './constants';
|
||||
import { OrbitalElements } from '../models/body.model';
|
||||
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
@@ -7,15 +7,21 @@ const TWO_PI = Math.PI * 2;
|
||||
|
||||
/**
|
||||
* Fills in the elements the Kepler propagator needs but that some sources (e.g. exoplanets,
|
||||
* see `ExoplanetRecord.orbit: Partial<OrbitalElements>`) don't report: inclination, longitude
|
||||
* of ascending node, mean anomaly at epoch, and the epoch itself. Missing angles default to
|
||||
* zero (a face-on, unrotated ellipse) and the missing epoch defaults to J2000 — enough to draw
|
||||
* a plausible, period-correct orbit even without full data.
|
||||
* see `ExoplanetRecord.orbit: Partial<OrbitalElements>`) don't report: eccentricity,
|
||||
* inclination, longitude of ascending node, mean anomaly at epoch, and the epoch itself.
|
||||
* Missing angles default to zero (a face-on, unrotated ellipse) and the missing epoch defaults
|
||||
* to J2000 — enough to draw a plausible, period-correct orbit even without full data.
|
||||
*
|
||||
* A missing eccentricity defaults to 0, a circle. That is the conventional assumption for an
|
||||
* orbit whose shape has not been constrained, and it is also the only honest one available: the
|
||||
* semi-major axis alone says nothing about elongation. It matters because the archive publishes
|
||||
* an axis far more often than an eccentricity — 1509 exoplanets have the first without the
|
||||
* second — and treating those as undrawable simply hid them.
|
||||
*/
|
||||
export function resolveOrbitalElements(partial: Partial<OrbitalElements> & Pick<OrbitalElements, 'semiMajorAxisAu' | 'eccentricity'>): OrbitalElements {
|
||||
export function resolveOrbitalElements(partial: Partial<OrbitalElements> & Pick<OrbitalElements, 'semiMajorAxisAu'>): OrbitalElements {
|
||||
return {
|
||||
semiMajorAxisAu: partial.semiMajorAxisAu,
|
||||
eccentricity: partial.eccentricity,
|
||||
eccentricity: partial.eccentricity ?? 0,
|
||||
inclinationDeg: partial.inclinationDeg ?? 0,
|
||||
longitudeOfAscendingNodeDeg: partial.longitudeOfAscendingNodeDeg ?? 0,
|
||||
argumentOfPeriapsisDeg: partial.argumentOfPeriapsisDeg ?? 0,
|
||||
@@ -24,6 +30,33 @@ export function resolveOrbitalElements(partial: Partial<OrbitalElements> & Pick<
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a partially-specified orbit can actually be propagated as an ellipse.
|
||||
*
|
||||
* Everything downstream — the mean motion, the Kepler solver, the ellipse sampling — assumes a
|
||||
* closed elliptical orbit around a positive semi-major axis. Feed it anything else and it does
|
||||
* not throw: `sqrt` of a negative number and division by zero both yield `NaN`, which
|
||||
* propagates silently into the vertex buffer and poisons the geometry's bounding sphere, taking
|
||||
* out culling for the whole object rather than just the bad orbit.
|
||||
*
|
||||
* A missing eccentricity is fine and defaults to a circle (see {@link resolveOrbitalElements});
|
||||
* a present but non-elliptical one (`e >= 1`, an escape trajectory) is not, since no ellipse
|
||||
* describes it.
|
||||
*/
|
||||
export function isPropagatableOrbit(
|
||||
partial: Partial<OrbitalElements>
|
||||
): partial is Partial<OrbitalElements> & Pick<OrbitalElements, 'semiMajorAxisAu'> {
|
||||
const { semiMajorAxisAu, eccentricity } = partial;
|
||||
|
||||
if (semiMajorAxisAu === undefined || !Number.isFinite(semiMajorAxisAu) || semiMajorAxisAu <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (eccentricity !== undefined && (!Number.isFinite(eccentricity) || eccentricity < 0 || eccentricity >= 1)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Mean motion (rad/day) of a body via Kepler's third law: n = sqrt(GM / a^3). */
|
||||
export function meanMotionRadPerDay(semiMajorAxisAu: number, gmAu3PerDay2: number): number {
|
||||
return Math.sqrt(gmAu3PerDay2 / (semiMajorAxisAu * semiMajorAxisAu * semiMajorAxisAu));
|
||||
@@ -34,6 +67,72 @@ export function orbitalPeriodDays(semiMajorAxisAu: number, gmAu3PerDay2: number)
|
||||
return TWO_PI / meanMotionRadPerDay(semiMajorAxisAu, gmAu3PerDay2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gravitational parameter implied by a measured orbital period — the inverse of
|
||||
* {@link orbitalPeriodDays}: `GM = n^2 * a^3`, with `n = 2*pi / T`.
|
||||
*
|
||||
* This is how an exoplanet's host star gets its mass into the propagator. Nothing about the
|
||||
* star needs to be known or guessed: the period and the semi-major axis between them pin the
|
||||
* gravitational parameter exactly.
|
||||
*/
|
||||
export function gravitationalParameterFromPeriod(semiMajorAxisAu: number, periodDays: number): number {
|
||||
const meanMotion = TWO_PI / periodDays;
|
||||
return meanMotion * meanMotion * semiMajorAxisAu * semiMajorAxisAu * semiMajorAxisAu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plausible range for a host star's mass, in solar masses — from below the hydrogen-burning
|
||||
* limit to beyond the heaviest known stars. Used only to reject a derived value that cannot be
|
||||
* a star, which would otherwise send a planet spinning at a visibly absurd rate.
|
||||
*/
|
||||
const MIN_PLAUSIBLE_STELLAR_MASS_SOLAR = 0.01;
|
||||
const MAX_PLAUSIBLE_STELLAR_MASS_SOLAR = 150;
|
||||
|
||||
function isPositiveFinite(value: number | undefined): value is number {
|
||||
return value !== undefined && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The gravitational parameter to propagate a planet with, in AU^3/day^2, best source first:
|
||||
*
|
||||
* 1. **Its measured orbital period.** Exact, and independent of any stellar model.
|
||||
* 2. **Its host star's measured mass.**
|
||||
* 3. **One solar mass**, as a last resort.
|
||||
*
|
||||
* Falling back to the Sun is a real approximation, not a neutral default. Most exoplanet hosts
|
||||
* are red dwarfs far lighter than the Sun, and a heavier central mass pulls harder and shortens
|
||||
* the period, so assuming solar mass makes their planets whirl round far too fast. TRAPPIST-1
|
||||
* is 0.09 solar masses; its planets were completing an orbit in roughly a third of the true
|
||||
* time.
|
||||
*/
|
||||
export function resolveGravitationalParameter(input: {
|
||||
semiMajorAxisAu: number;
|
||||
periodDays?: number;
|
||||
hostStarMassSolar?: number;
|
||||
}): number {
|
||||
const { semiMajorAxisAu, periodDays, hostStarMassSolar } = input;
|
||||
|
||||
if (isPositiveFinite(periodDays) && isPositiveFinite(semiMajorAxisAu)) {
|
||||
const derived = gravitationalParameterFromPeriod(semiMajorAxisAu, periodDays);
|
||||
const impliedMassSolar = derived / GM_SUN_AU3_PER_DAY2;
|
||||
// A period and axis drawn from disagreeing solutions can imply something that is not a
|
||||
// star; prefer a known-approximate answer over a confidently wrong one.
|
||||
if (impliedMassSolar >= MIN_PLAUSIBLE_STELLAR_MASS_SOLAR && impliedMassSolar <= MAX_PLAUSIBLE_STELLAR_MASS_SOLAR) {
|
||||
return derived;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isPositiveFinite(hostStarMassSolar) &&
|
||||
hostStarMassSolar >= MIN_PLAUSIBLE_STELLAR_MASS_SOLAR &&
|
||||
hostStarMassSolar <= MAX_PLAUSIBLE_STELLAR_MASS_SOLAR
|
||||
) {
|
||||
return GM_SUN_AU3_PER_DAY2 * hostStarMassSolar;
|
||||
}
|
||||
|
||||
return GM_SUN_AU3_PER_DAY2;
|
||||
}
|
||||
|
||||
/** Normalizes an angle (radians) into [0, 2*pi). */
|
||||
function normalizeAngle(angleRad: number): number {
|
||||
const wrapped = angleRad % TWO_PI;
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
bulkDensityGramsPerCm3,
|
||||
classifyPlanet,
|
||||
EARTH_DENSITY_G_PER_CM3,
|
||||
equilibriumTemperatureK,
|
||||
paletteFor,
|
||||
planetAppearance,
|
||||
PLANET_CLASS_LABELS,
|
||||
PlanetClass,
|
||||
polarCapExtentDeg,
|
||||
seedFromId
|
||||
} from './planet-appearance';
|
||||
|
||||
const EARTH_RADII = { mercury: 0.383, venus: 0.949, earth: 1, mars: 0.532, jupiter: 10.97, saturn: 9.14, uranus: 3.98, neptune: 3.86, europa: 0.245, phobos: 0.0017 };
|
||||
|
||||
describe('bulkDensityGramsPerCm3', () => {
|
||||
it('gives Earth its own density, by construction', () => {
|
||||
expect(bulkDensityGramsPerCm3(1, 1)).toBeCloseTo(EARTH_DENSITY_G_PER_CM3, 9);
|
||||
});
|
||||
|
||||
it('separates a ball of iron from a ball of hydrogen, which is what it is for', () => {
|
||||
// Mercury is 5.4 g/cm3 and mostly core; Saturn is 0.69 and would float.
|
||||
expect(bulkDensityGramsPerCm3(0.055, EARTH_RADII.mercury)).toBeCloseTo(5.4, 0);
|
||||
expect(bulkDensityGramsPerCm3(95.2, EARTH_RADII.saturn)).toBeCloseTo(0.69, 1);
|
||||
});
|
||||
|
||||
it('has no answer without both numbers', () => {
|
||||
expect(bulkDensityGramsPerCm3(undefined, 1)).toBeNull();
|
||||
expect(bulkDensityGramsPerCm3(1, undefined)).toBeNull();
|
||||
expect(bulkDensityGramsPerCm3(0, 1)).toBeNull();
|
||||
expect(bulkDensityGramsPerCm3(1, -1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('equilibriumTemperatureK', () => {
|
||||
// The published equilibrium temperatures, which this must reproduce to be worth anything.
|
||||
it.each([
|
||||
['Earth', 1, 1, 255],
|
||||
['Mars', 1, 1.524, 206],
|
||||
['Jupiter', 1, 5.204, 112],
|
||||
['Neptune', 1, 30.07, 46]
|
||||
])('reproduces the published equilibrium temperature of %s', (_name, luminosity, semiMajorAxisAu, expected) => {
|
||||
expect(equilibriumTemperatureK(luminosity, semiMajorAxisAu)).toBeCloseTo(expected, -0.5);
|
||||
});
|
||||
|
||||
it('follows the inverse square root of distance', () => {
|
||||
const near = equilibriumTemperatureK(1, 1)!;
|
||||
const far = equilibriumTemperatureK(1, 4)!;
|
||||
expect(near / far).toBeCloseTo(2, 6);
|
||||
});
|
||||
|
||||
it('follows the fourth root of luminosity, which is why a rough luminosity still serves', () => {
|
||||
const dim = equilibriumTemperatureK(1, 1)!;
|
||||
const bright = equilibriumTemperatureK(16, 1)!;
|
||||
expect(bright / dim).toBeCloseTo(2, 6);
|
||||
});
|
||||
|
||||
it('puts a hot Jupiter where a hot Jupiter is', () => {
|
||||
// 51 Pegasi b: 0.052 AU from a slightly super-solar star, published near 1200 K.
|
||||
expect(equilibriumTemperatureK(1.3, 0.052)!).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it('cools a world as its albedo rises, as a fourth root', () => {
|
||||
expect(equilibriumTemperatureK(1, 1, 0.8)!).toBeLessThan(equilibriumTemperatureK(1, 1, 0)!);
|
||||
});
|
||||
|
||||
it('has no answer without a star or an orbit', () => {
|
||||
expect(equilibriumTemperatureK(null, 1)).toBeNull();
|
||||
expect(equilibriumTemperatureK(1, undefined)).toBeNull();
|
||||
expect(equilibriumTemperatureK(0, 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyPlanet', () => {
|
||||
/** Every solar-system body this app carries, at its real size and equilibrium temperature. */
|
||||
it.each<[string, { radiusEarth?: number; massEarth?: number; equilibriumTemperatureK?: number }, PlanetClass]>([
|
||||
['Mercury', { radiusEarth: EARTH_RADII.mercury, massEarth: 0.055, equilibriumTemperatureK: 410 }, 'scorched'],
|
||||
['Earth', { radiusEarth: 1, massEarth: 1, equilibriumTemperatureK: 255 }, 'temperate'],
|
||||
['Mars', { radiusEarth: EARTH_RADII.mars, massEarth: 0.107, equilibriumTemperatureK: 206 }, 'temperate'],
|
||||
['Jupiter', { radiusEarth: EARTH_RADII.jupiter, massEarth: 317.8, equilibriumTemperatureK: 112 }, 'gasGiant'],
|
||||
['Saturn', { radiusEarth: EARTH_RADII.saturn, massEarth: 95.2, equilibriumTemperatureK: 82 }, 'gasGiant'],
|
||||
['Uranus', { radiusEarth: EARTH_RADII.uranus, massEarth: 14.5, equilibriumTemperatureK: 58 }, 'iceGiant'],
|
||||
['Neptune', { radiusEarth: EARTH_RADII.neptune, massEarth: 17.1, equilibriumTemperatureK: 46 }, 'iceGiant'],
|
||||
['Europa', { radiusEarth: EARTH_RADII.europa, equilibriumTemperatureK: 112 }, 'icy'],
|
||||
['51 Peg b', { massEarth: 193.9, equilibriumTemperatureK: 1227 }, 'hotGasGiant'],
|
||||
['GJ 1214 b', { radiusEarth: 2.733, massEarth: 8.4, equilibriumTemperatureK: 596 }, 'subNeptune']
|
||||
])('puts %s in the right class', (_name, measurements, expected) => {
|
||||
expect(classifyPlanet(measurements)).toBe(expected);
|
||||
});
|
||||
|
||||
it('tells a gas giant from an ice giant by size, since temperature cannot', () => {
|
||||
// Jupiter is 110 K and Neptune is 47 K: both freezing, and the difference between them is
|
||||
// how much hydrogen they hold, not how cold they are.
|
||||
const cold = { equilibriumTemperatureK: 100 };
|
||||
expect(classifyPlanet({ ...cold, radiusEarth: EARTH_RADII.jupiter })).toBe('gasGiant');
|
||||
expect(classifyPlanet({ ...cold, radiusEarth: EARTH_RADII.neptune })).toBe('iceGiant');
|
||||
});
|
||||
|
||||
it('calls any giant hot once it is hot, whichever kind it was', () => {
|
||||
for (const radiusEarth of [EARTH_RADII.jupiter, EARTH_RADII.neptune]) {
|
||||
expect(classifyPlanet({ radiusEarth, equilibriumTemperatureK: 1400 })).toBe('hotGasGiant');
|
||||
}
|
||||
});
|
||||
|
||||
it('lets density override temperature at both extremes', () => {
|
||||
// Iron whatever the weather...
|
||||
expect(classifyPlanet({ radiusEarth: 1, massEarth: 1.6, equilibriumTemperatureK: 255 })).toBe('iron');
|
||||
// ...and too light to be rock means ice, even where rock would be solid.
|
||||
expect(classifyPlanet({ radiusEarth: 1.5, massEarth: 1, equilibriumTemperatureK: 250 })).toBe('icy');
|
||||
});
|
||||
|
||||
it('melts a rocky world that is hot enough', () => {
|
||||
expect(classifyPlanet({ radiusEarth: 1, equilibriumTemperatureK: 1500 })).toBe('lava');
|
||||
});
|
||||
|
||||
it('refuses to call a 11 km moon temperate on the strength of its orbital distance', () => {
|
||||
// Phobos sits at Mars's distance and so at Mars's temperature, and is an airless rock.
|
||||
expect(classifyPlanet({ radiusEarth: EARTH_RADII.phobos, equilibriumTemperatureK: 206 })).toBe('rocky');
|
||||
});
|
||||
|
||||
it('falls back to size alone when the host star is unknown', () => {
|
||||
expect(classifyPlanet({ radiusEarth: 1 })).toBe('rocky');
|
||||
expect(classifyPlanet({ radiusEarth: EARTH_RADII.jupiter })).toBe('gasGiant');
|
||||
expect(classifyPlanet({ radiusEarth: 2.5 })).toBe('subNeptune');
|
||||
});
|
||||
|
||||
it('classifies from a mass alone, for the planets only radial velocity has seen', () => {
|
||||
expect(classifyPlanet({ massEarth: 300 })).toBe('gasGiant');
|
||||
expect(classifyPlanet({ massEarth: 15 })).toBe('iceGiant');
|
||||
expect(classifyPlanet({ massEarth: 4 })).toBe('subNeptune');
|
||||
expect(classifyPlanet({ massEarth: 1 })).toBe('rocky');
|
||||
});
|
||||
|
||||
it('prefers radius over mass, since radius is what the classes are defined by', () => {
|
||||
// A puffy planet as massive as Neptune but the size of Jupiter is a gas giant.
|
||||
expect(classifyPlanet({ radiusEarth: EARTH_RADII.jupiter, massEarth: 15 })).toBe('gasGiant');
|
||||
});
|
||||
|
||||
it('always returns a class, whatever it is given', () => {
|
||||
expect(classifyPlanet({})).toBe('rocky');
|
||||
});
|
||||
});
|
||||
|
||||
describe('polarCapExtentDeg', () => {
|
||||
it('grows caps as a world cools, which is the visible consequence of the derived temperature', () => {
|
||||
const warm = polarCapExtentDeg('temperate', 280)!;
|
||||
const cool = polarCapExtentDeg('temperate', 230)!;
|
||||
const cold = polarCapExtentDeg('temperate', 190)!;
|
||||
expect(warm).toBeLessThan(cool);
|
||||
expect(cool).toBeLessThan(cold);
|
||||
});
|
||||
|
||||
it('covers a frozen world entirely and leaves a warm one bare', () => {
|
||||
expect(polarCapExtentDeg('icy', 100)).toBe(90);
|
||||
expect(polarCapExtentDeg('rocky', 400)).toBeNull();
|
||||
});
|
||||
|
||||
it('gives Earth a cap that stops well short of the tropics', () => {
|
||||
const earth = polarCapExtentDeg('temperate', 255)!;
|
||||
expect(earth).toBeGreaterThan(5);
|
||||
expect(earth).toBeLessThan(45);
|
||||
});
|
||||
|
||||
it('does not put ice on a world where ice is not the question', () => {
|
||||
for (const planetClass of ['gasGiant', 'hotGasGiant', 'iceGiant', 'subNeptune', 'lava'] as PlanetClass[]) {
|
||||
expect(polarCapExtentDeg(planetClass, 100)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('has no answer without a temperature', () => {
|
||||
expect(polarCapExtentDeg('temperate', null)).toBeNull();
|
||||
expect(polarCapExtentDeg('temperate', undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('paletteFor', () => {
|
||||
const ALL_CLASSES = Object.keys(PLANET_CLASS_LABELS) as PlanetClass[];
|
||||
|
||||
it('has a palette and a label for every class', () => {
|
||||
for (const planetClass of ALL_CLASSES) {
|
||||
expect(paletteFor(planetClass)).toBeDefined();
|
||||
expect(PLANET_CLASS_LABELS[planetClass].length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every channel inside the displayable range', () => {
|
||||
for (const planetClass of ALL_CLASSES) {
|
||||
const palette = paletteFor(planetClass);
|
||||
for (const tone of [palette.low, palette.mid, palette.high, palette.cap]) {
|
||||
for (const channel of tone) {
|
||||
expect(channel).toBeGreaterThanOrEqual(0);
|
||||
expect(channel).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
expect(palette.contrast).toBeGreaterThan(0);
|
||||
expect(palette.contrast).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('bands the worlds with a fluid envelope and gives terrain to the ones with a surface', () => {
|
||||
for (const planetClass of ['gasGiant', 'hotGasGiant', 'iceGiant', 'subNeptune'] as PlanetClass[]) {
|
||||
expect(paletteFor(planetClass).structure).toBe('banded');
|
||||
}
|
||||
for (const planetClass of ['lava', 'scorched', 'iron', 'rocky', 'temperate', 'icy'] as PlanetClass[]) {
|
||||
expect(paletteFor(planetClass).structure).toBe('terrain');
|
||||
}
|
||||
});
|
||||
|
||||
it('makes an ice giant blue and a hot giant red, following what each is made of', () => {
|
||||
// Methane absorbs red light, which is exactly why Uranus and Neptune look the way they do.
|
||||
const iceGiant = paletteFor('iceGiant').mid;
|
||||
expect(iceGiant[2]).toBeGreaterThan(iceGiant[0]);
|
||||
const hot = paletteFor('hotGasGiant').mid;
|
||||
expect(hot[0]).toBeGreaterThan(hot[2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedFromId', () => {
|
||||
it('is stable, so a world looks the same on every visit', () => {
|
||||
expect(seedFromId('Kepler-186 f')).toBe(seedFromId('Kepler-186 f'));
|
||||
});
|
||||
|
||||
it('separates bodies that differ only slightly in name', () => {
|
||||
expect(seedFromId('TRAPPIST-1 e')).not.toBe(seedFromId('TRAPPIST-1 f'));
|
||||
});
|
||||
|
||||
it('stays a non-negative 32-bit integer', () => {
|
||||
for (const id of ['', 'a', 'Kepler-186 f', 'HD 209458 b']) {
|
||||
const seed = seedFromId(id);
|
||||
expect(Number.isInteger(seed)).toBe(true);
|
||||
expect(seed).toBeGreaterThanOrEqual(0);
|
||||
expect(seed).toBeLessThan(2 ** 32);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('planetAppearance', () => {
|
||||
it('derives the whole chain from published measurements', () => {
|
||||
const earth = planetAppearance({ id: 'earth', radiusEarth: 1, massEarth: 1, semiMajorAxisAu: 1, hostLuminositySolar: 1 });
|
||||
|
||||
expect(earth.planetClass).toBe('temperate');
|
||||
expect(earth.equilibriumTemperatureK).toBeCloseTo(255, -0.5);
|
||||
expect(earth.bulkDensityGramsPerCm3).toBeCloseTo(EARTH_DENSITY_G_PER_CM3, 6);
|
||||
expect(earth.polarCapExtentDeg).toBeGreaterThan(0);
|
||||
expect(earth.palette.structure).toBe('terrain');
|
||||
});
|
||||
|
||||
it('reports what it could not derive as null rather than guessing it', () => {
|
||||
const unknown = planetAppearance({ id: 'x', radiusEarth: 1 });
|
||||
expect(unknown.equilibriumTemperatureK).toBeNull();
|
||||
expect(unknown.bulkDensityGramsPerCm3).toBeNull();
|
||||
expect(unknown.polarCapExtentDeg).toBeNull();
|
||||
expect(unknown.planetClass).toBe('rocky');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* What a world probably looks like, derived from what has been measured about it.
|
||||
*
|
||||
* No exoplanet's surface has ever been imaged, and a handful of the solar system's own moons
|
||||
* have no usable photograph in this app's asset set either. Rather than paint those bodies a
|
||||
* flat colour chosen by category, this module reasons from the numbers that *are* published —
|
||||
* radius, mass, orbital distance, and the host star's luminosity — to a temperature, a bulk
|
||||
* density, and from those to a class of world with a palette and a surface structure.
|
||||
*
|
||||
* The chain is: mass and radius give density, which separates rock from ice from gas; the star's
|
||||
* luminosity and the orbital distance give an equilibrium temperature, which decides whether
|
||||
* that material is molten, solid, or frozen. Both steps are standard, and both are stated on
|
||||
* screen — this produces a *derived* appearance, never a claim about an observation.
|
||||
*/
|
||||
|
||||
/** Equilibrium temperature of a body at 1 AU from the Sun with zero albedo, in kelvin. */
|
||||
export const SOLAR_EQUILIBRIUM_TEMPERATURE_K = 278.6;
|
||||
|
||||
/**
|
||||
* Default Bond albedo where none is published, which is all of them. The solar system's rocky
|
||||
* bodies cluster near this: Earth 0.31, Mars 0.25, Mercury 0.09, the Moon 0.11, and the giants
|
||||
* 0.29-0.5. Anything in that range moves the temperature by a few per cent, since it enters as
|
||||
* a fourth root.
|
||||
*/
|
||||
export const DEFAULT_BOND_ALBEDO = 0.3;
|
||||
|
||||
export const EARTH_RADIUS_KM = 6371;
|
||||
/** Earth's mean bulk density, in g/cm3 — the reference every other world is compared against. */
|
||||
export const EARTH_DENSITY_G_PER_CM3 = 5.51;
|
||||
|
||||
/**
|
||||
* Class boundaries in Earth radii.
|
||||
*
|
||||
* Below the rocky ceiling a world cannot hold onto hydrogen. Above the gas-giant floor it is
|
||||
* mostly hydrogen and helium. Between sit the ice giants — Uranus and Neptune are both close to
|
||||
* 3.9 Earth radii — and below those the sub-Neptunes, the commonest kind of planet found and the
|
||||
* one with no solar-system example at all.
|
||||
*/
|
||||
const ROCKY_MAX_RADIUS_EARTH = 1.8;
|
||||
const ICE_GIANT_MIN_RADIUS_EARTH = 3.5;
|
||||
const GAS_GIANT_MIN_RADIUS_EARTH = 6;
|
||||
|
||||
/**
|
||||
* The same ladder in Earth masses, for the ~1400 planets with a published mass and no radius —
|
||||
* mostly radial-velocity detections, which measure mass and never see a transit.
|
||||
*/
|
||||
const SUB_NEPTUNE_MIN_MASS_EARTH = 2;
|
||||
const ICE_GIANT_MIN_MASS_EARTH = 8;
|
||||
const GAS_GIANT_MIN_MASS_EARTH = 50;
|
||||
|
||||
/**
|
||||
* Temperature boundaries in kelvin.
|
||||
*
|
||||
* The temperate band is the conservative one: Earth's equilibrium temperature is 255 K and
|
||||
* Mars's is 206 K, both well inside it, while Venus's 300 K sits outside — which is the right
|
||||
* answer here, since equilibrium temperature deliberately ignores the greenhouse effect that
|
||||
* takes Venus's actual surface to 737 K.
|
||||
*/
|
||||
const LAVA_MIN_K = 1200;
|
||||
const SCORCHED_MIN_K = 400;
|
||||
const TEMPERATE_MIN_K = 175;
|
||||
const TEMPERATE_MAX_K = 290;
|
||||
const HOT_GIANT_MIN_K = 900;
|
||||
|
||||
/**
|
||||
* Smallest world that gets called temperate, in Earth radii — about 1900 km, near the size below
|
||||
* which a body cannot hold an atmosphere at all. Without it, Phobos comes out "temperate" on the
|
||||
* strength of Mars's orbital distance, which is true of its temperature and absurd of the 11 km
|
||||
* airless rock itself.
|
||||
*/
|
||||
const TEMPERATE_MIN_RADIUS_EARTH = 0.3;
|
||||
|
||||
/** Density boundaries in g/cm3, either side of the rocky band. */
|
||||
const IRON_MIN_DENSITY = 7.5;
|
||||
const VOLATILE_MAX_DENSITY = 3;
|
||||
|
||||
/**
|
||||
* Bulk density in g/cm3 from a mass in Earth masses and a radius in Earth radii.
|
||||
*
|
||||
* This is the single most informative derived quantity about a planet: it is the difference
|
||||
* between a ball of iron, a ball of rock, a ball of water and a ball of hydrogen, and it comes
|
||||
* straight out of two published numbers with no modelling in between.
|
||||
*/
|
||||
export function bulkDensityGramsPerCm3(massEarth: number | undefined, radiusEarth: number | undefined): number | null {
|
||||
if (!massEarth || !radiusEarth || massEarth <= 0 || radiusEarth <= 0) {
|
||||
return null;
|
||||
}
|
||||
return EARTH_DENSITY_G_PER_CM3 * (massEarth / Math.pow(radiusEarth, 3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Equilibrium temperature in kelvin: the temperature at which a body re-radiates exactly the
|
||||
* starlight it absorbs.
|
||||
*
|
||||
* `T = 278.6 K * (L/Lsun)^(1/4) * (a/AU)^(-1/2) * (1-A)^(1/4)`, the standard blackbody balance
|
||||
* for a rapidly-rotating body. It ignores internal heat and any greenhouse effect, both of which
|
||||
* push the real surface warmer — Venus's surface is 737 K against an equilibrium 232 K. It is
|
||||
* nonetheless the right quantity here, because it is what decides the *state* of the material a
|
||||
* world is made of, which is what its surface looks like.
|
||||
*/
|
||||
export function equilibriumTemperatureK(
|
||||
luminositySolar: number | null | undefined,
|
||||
semiMajorAxisAu: number | undefined,
|
||||
bondAlbedo: number = DEFAULT_BOND_ALBEDO
|
||||
): number | null {
|
||||
if (!luminositySolar || !semiMajorAxisAu || luminositySolar <= 0 || semiMajorAxisAu <= 0) {
|
||||
return null;
|
||||
}
|
||||
return SOLAR_EQUILIBRIUM_TEMPERATURE_K * Math.pow(luminositySolar, 0.25) * Math.pow(semiMajorAxisAu, -0.5) * Math.pow(1 - bondAlbedo, 0.25);
|
||||
}
|
||||
|
||||
/**
|
||||
* The kinds of world this app distinguishes. Chosen to be the classes that actually look
|
||||
* different from each other, and that the available measurements can actually separate.
|
||||
*/
|
||||
export type PlanetClass = 'lava' | 'scorched' | 'iron' | 'rocky' | 'temperate' | 'icy' | 'subNeptune' | 'iceGiant' | 'gasGiant' | 'hotGasGiant';
|
||||
|
||||
export interface PlanetMeasurements {
|
||||
radiusEarth?: number;
|
||||
massEarth?: number;
|
||||
/** Equilibrium temperature, if it could be derived; `null`/absent when the star is unknown. */
|
||||
equilibriumTemperatureK?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts a world into a class from its measurements.
|
||||
*
|
||||
* Size decides the family and temperature decides the state within it, which is the order the
|
||||
* evidence actually supports: a radius separates a gas giant from a rock far more reliably than
|
||||
* any temperature can, and temperature then separates a molten rock from a frozen one.
|
||||
*
|
||||
* With no temperature — the case for a planet whose host star is not in the star catalogue —
|
||||
* every world falls back to the temperate-agnostic member of its family rather than being
|
||||
* guessed at.
|
||||
*/
|
||||
export function classifyPlanet(measurements: PlanetMeasurements): PlanetClass {
|
||||
const { radiusEarth, massEarth } = measurements;
|
||||
const temperature = measurements.equilibriumTemperatureK ?? null;
|
||||
const density = bulkDensityGramsPerCm3(massEarth, radiusEarth);
|
||||
const family = sizeFamily(radiusEarth, massEarth);
|
||||
|
||||
if (family === 'gasGiant' || family === 'iceGiant') {
|
||||
// Temperature separates a hot giant from a cold one but not a gas giant from an ice giant:
|
||||
// Jupiter's equilibrium temperature is 110 K and Neptune's is 47 K, both freezing. What
|
||||
// actually distinguishes them is how much hydrogen they hold, which is what size measures.
|
||||
return temperature !== null && temperature >= HOT_GIANT_MIN_K ? 'hotGasGiant' : family;
|
||||
}
|
||||
if (family === 'subNeptune') {
|
||||
return 'subNeptune';
|
||||
}
|
||||
|
||||
// Below the rocky ceiling. Density, where it is known, overrides temperature at both extremes:
|
||||
// an iron-rich world reads metallic whatever its temperature, and one too light to be rock is
|
||||
// an ice/water world even if it sits where rock would be solid.
|
||||
if (density !== null && density >= IRON_MIN_DENSITY) {
|
||||
return 'iron';
|
||||
}
|
||||
if (temperature !== null && temperature >= LAVA_MIN_K) {
|
||||
return 'lava';
|
||||
}
|
||||
if (density !== null && density <= VOLATILE_MAX_DENSITY && (temperature === null || temperature < TEMPERATE_MAX_K)) {
|
||||
return 'icy';
|
||||
}
|
||||
if (temperature === null) {
|
||||
return 'rocky';
|
||||
}
|
||||
if (temperature >= SCORCHED_MIN_K) {
|
||||
return 'scorched';
|
||||
}
|
||||
if (temperature < TEMPERATE_MIN_K) {
|
||||
return 'icy';
|
||||
}
|
||||
const bigEnoughForAnAtmosphere = radiusEarth === undefined || radiusEarth >= TEMPERATE_MIN_RADIUS_EARTH;
|
||||
return temperature <= TEMPERATE_MAX_K && bigEnoughForAnAtmosphere ? 'temperate' : 'rocky';
|
||||
}
|
||||
|
||||
/**
|
||||
* Which family a world's bulk puts it in, from a radius where one is published and from a mass
|
||||
* where only that is. Radius is preferred: it is what the classes are actually defined by, and
|
||||
* mass alone leaves a dense super-Earth and a puffy sub-Neptune indistinguishable.
|
||||
*/
|
||||
function sizeFamily(radiusEarth: number | undefined, massEarth: number | undefined): 'rocky' | 'subNeptune' | 'iceGiant' | 'gasGiant' {
|
||||
if (radiusEarth !== undefined && radiusEarth > 0) {
|
||||
if (radiusEarth >= GAS_GIANT_MIN_RADIUS_EARTH) {
|
||||
return 'gasGiant';
|
||||
}
|
||||
if (radiusEarth >= ICE_GIANT_MIN_RADIUS_EARTH) {
|
||||
return 'iceGiant';
|
||||
}
|
||||
return radiusEarth > ROCKY_MAX_RADIUS_EARTH ? 'subNeptune' : 'rocky';
|
||||
}
|
||||
|
||||
const mass = massEarth ?? 0;
|
||||
if (mass >= GAS_GIANT_MIN_MASS_EARTH) {
|
||||
return 'gasGiant';
|
||||
}
|
||||
if (mass >= ICE_GIANT_MIN_MASS_EARTH) {
|
||||
return 'iceGiant';
|
||||
}
|
||||
return mass >= SUB_NEPTUNE_MIN_MASS_EARTH ? 'subNeptune' : 'rocky';
|
||||
}
|
||||
|
||||
/** Human-readable name for a class, for the info panel. */
|
||||
export const PLANET_CLASS_LABELS: Readonly<Record<PlanetClass, string>> = {
|
||||
lava: 'Molten rock',
|
||||
scorched: 'Scorched rock',
|
||||
iron: 'Iron-rich world',
|
||||
rocky: 'Rocky world',
|
||||
temperate: 'Temperate rock',
|
||||
icy: 'Ice world',
|
||||
subNeptune: 'Sub-Neptune',
|
||||
iceGiant: 'Ice giant',
|
||||
gasGiant: 'Gas giant',
|
||||
hotGasGiant: 'Hot gas giant'
|
||||
};
|
||||
|
||||
/** An RGB triple in 0-1, the form the texture generator and Three.js both want. */
|
||||
export type Rgb = readonly [number, number, number];
|
||||
|
||||
/**
|
||||
* The palette and surface structure each class is drawn with.
|
||||
*
|
||||
* `low`/`mid`/`high` are the three tones the surface is built from — basin floor, general
|
||||
* surface, highland or cloud top — and `cap` is the polar tone. Colours are reasoned from the
|
||||
* chemistry each class implies: silicates and basalt are grey-brown, hot silicate cloud decks
|
||||
* glow red, ammonia clouds are cream and ochre, and methane absorbs red light, which is exactly
|
||||
* why Uranus and Neptune are the colour they are.
|
||||
*/
|
||||
export interface PlanetPalette {
|
||||
readonly low: Rgb;
|
||||
readonly mid: Rgb;
|
||||
readonly high: Rgb;
|
||||
readonly cap: Rgb;
|
||||
/** `banded` for a fluid envelope with zonal flow, `terrain` for a solid surface. */
|
||||
readonly structure: 'banded' | 'terrain';
|
||||
/** How much the three tones separate, 0-1. Hazy worlds are flat, airless ones are stark. */
|
||||
readonly contrast: number;
|
||||
}
|
||||
|
||||
const PALETTES: Readonly<Record<PlanetClass, PlanetPalette>> = {
|
||||
// Basalt darkened almost to black, cut by exposed magma. Real molten silicate at 1500 K glows
|
||||
// a dull orange-red, not the yellow-white of a much hotter furnace.
|
||||
lava: { low: [0.09, 0.06, 0.06], mid: [0.24, 0.13, 0.1], high: [0.95, 0.35, 0.12], cap: [0.32, 0.12, 0.08], structure: 'terrain', contrast: 0.95 },
|
||||
// Baked rock with the volatiles long gone: Mercury's colour, which is what is left behind.
|
||||
scorched: { low: [0.24, 0.2, 0.18], mid: [0.42, 0.36, 0.31], high: [0.6, 0.53, 0.46], cap: [0.45, 0.4, 0.36], structure: 'terrain', contrast: 0.8 },
|
||||
// A world dense enough to be mostly metal reads darker and greyer than silicate rock.
|
||||
iron: { low: [0.16, 0.15, 0.16], mid: [0.33, 0.31, 0.32], high: [0.52, 0.5, 0.53], cap: [0.4, 0.39, 0.41], structure: 'terrain', contrast: 0.7 },
|
||||
rocky: { low: [0.25, 0.21, 0.18], mid: [0.45, 0.38, 0.31], high: [0.66, 0.58, 0.48], cap: [0.78, 0.78, 0.8], structure: 'terrain', contrast: 0.7 },
|
||||
// Where water can be liquid. Deliberately restrained: this is a temperature, not a detection.
|
||||
temperate: { low: [0.12, 0.2, 0.32], mid: [0.3, 0.36, 0.36], high: [0.55, 0.52, 0.42], cap: [0.9, 0.93, 0.96], structure: 'terrain', contrast: 0.6 },
|
||||
icy: { low: [0.55, 0.62, 0.7], mid: [0.75, 0.81, 0.86], high: [0.92, 0.95, 0.98], cap: [0.97, 0.98, 1.0], structure: 'terrain', contrast: 0.45 },
|
||||
// The commonest planet found and the one with no solar-system example. A thick hydrogen haze
|
||||
// over an unseen interior, so: banded, but with almost no contrast to band.
|
||||
subNeptune: { low: [0.42, 0.47, 0.5], mid: [0.58, 0.63, 0.64], high: [0.72, 0.76, 0.75], cap: [0.62, 0.67, 0.68], structure: 'banded', contrast: 0.22 },
|
||||
// Methane absorbs red light; what comes back out is the blue-green of Uranus and Neptune.
|
||||
iceGiant: { low: [0.13, 0.32, 0.55], mid: [0.24, 0.5, 0.72], high: [0.55, 0.78, 0.88], cap: [0.35, 0.6, 0.78], structure: 'banded', contrast: 0.45 },
|
||||
// Ammonia cloud tops over ochre organics: the Jupiter/Saturn palette.
|
||||
gasGiant: { low: [0.45, 0.32, 0.22], mid: [0.72, 0.6, 0.44], high: [0.92, 0.87, 0.76], cap: [0.6, 0.52, 0.42], structure: 'banded', contrast: 0.7 },
|
||||
// Too hot for ammonia or water clouds; silicate and alkali-metal cloud decks over a glowing
|
||||
// interior, which is why hot Jupiters are modelled as deep red rather than as bright ones.
|
||||
hotGasGiant: { low: [0.28, 0.08, 0.07], mid: [0.55, 0.18, 0.12], high: [0.85, 0.42, 0.2], cap: [0.4, 0.14, 0.1], structure: 'banded', contrast: 0.6 }
|
||||
};
|
||||
|
||||
export function paletteFor(planetClass: PlanetClass): PlanetPalette {
|
||||
return PALETTES[planetClass];
|
||||
}
|
||||
|
||||
/**
|
||||
* Latitude, in degrees from the pole, that polar ice reaches down to — or `null` for a world
|
||||
* where ice is not the question.
|
||||
*
|
||||
* Genuinely physical, and the clearest visible consequence of the derived temperature: caps
|
||||
* grow as a world cools. They are absent above the point where water cannot be stable anywhere
|
||||
* and cover the whole globe below the point where it cannot melt anywhere.
|
||||
*/
|
||||
export function polarCapExtentDeg(planetClass: PlanetClass, temperatureK: number | null | undefined): number | null {
|
||||
if (temperatureK === null || temperatureK === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (planetClass !== 'temperate' && planetClass !== 'rocky' && planetClass !== 'icy') {
|
||||
return null;
|
||||
}
|
||||
if (temperatureK >= TEMPERATE_MAX_K) {
|
||||
return null;
|
||||
}
|
||||
if (temperatureK <= TEMPERATE_MIN_K) {
|
||||
return 90;
|
||||
}
|
||||
// Linear between the two: nothing at the warm end, global at the cold end.
|
||||
return 90 * ((TEMPERATE_MAX_K - temperatureK) / (TEMPERATE_MAX_K - TEMPERATE_MIN_K));
|
||||
}
|
||||
|
||||
/** Everything the texture generator needs, and everything the info panel reports. */
|
||||
export interface PlanetAppearance {
|
||||
readonly planetClass: PlanetClass;
|
||||
readonly palette: PlanetPalette;
|
||||
readonly equilibriumTemperatureK: number | null;
|
||||
readonly bulkDensityGramsPerCm3: number | null;
|
||||
readonly polarCapExtentDeg: number | null;
|
||||
/** Stable per body, so a world looks the same on every visit. */
|
||||
readonly seed: number;
|
||||
}
|
||||
|
||||
/** Stable 32-bit hash of a body id, so the same world is generated identically every time. */
|
||||
export function seedFromId(id: string): number {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < id.length; index++) {
|
||||
hash ^= id.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full derivation, from published measurements to a drawable appearance.
|
||||
*
|
||||
* `hostLuminositySolar` is the star's total output in solar units — see `stellar.ts`, which
|
||||
* derives it from the star catalogue's own magnitude and distance. Without it there is no
|
||||
* temperature, and the classification falls back to what size and density alone can say.
|
||||
*/
|
||||
export function planetAppearance(input: {
|
||||
id: string;
|
||||
radiusEarth?: number;
|
||||
massEarth?: number;
|
||||
semiMajorAxisAu?: number;
|
||||
hostLuminositySolar?: number | null;
|
||||
}): PlanetAppearance {
|
||||
const temperature = equilibriumTemperatureK(input.hostLuminositySolar, input.semiMajorAxisAu);
|
||||
const planetClass = classifyPlanet({ radiusEarth: input.radiusEarth, massEarth: input.massEarth, equilibriumTemperatureK: temperature });
|
||||
|
||||
return {
|
||||
planetClass,
|
||||
palette: paletteFor(planetClass),
|
||||
equilibriumTemperatureK: temperature,
|
||||
bulkDensityGramsPerCm3: bulkDensityGramsPerCm3(input.massEarth, input.radiusEarth),
|
||||
polarCapExtentDeg: polarCapExtentDeg(planetClass, temperature),
|
||||
seed: seedFromId(input.id)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseSpectralClass, SPECTRAL_CLASSES, spectralTypeToColorIndex } from './spectral';
|
||||
|
||||
describe('parseSpectralClass', () => {
|
||||
it('reads a clean class and subclass', () => {
|
||||
expect(parseSpectralClass('M3.5')).toEqual({ spectralClass: 'M', subclass: 3.5 });
|
||||
expect(parseSpectralClass('G2V')).toEqual({ spectralClass: 'G', subclass: 2 });
|
||||
});
|
||||
|
||||
it('defaults the subclass to 0 when only a class is given', () => {
|
||||
expect(parseSpectralClass('K')).toEqual({ spectralClass: 'K', subclass: 0 });
|
||||
});
|
||||
|
||||
it('accepts the lowercase forms HYG actually ships', () => {
|
||||
// 354 nearby stars are classified as a bare lowercase "m".
|
||||
expect(parseSpectralClass('m')).toEqual({ spectralClass: 'M', subclass: 0 });
|
||||
expect(parseSpectralClass('k')).toEqual({ spectralClass: 'K', subclass: 0 });
|
||||
});
|
||||
|
||||
it('skips a luminosity prefix to find the class', () => {
|
||||
expect(parseSpectralClass('dM4')?.spectralClass).toBe('M');
|
||||
expect(parseSpectralClass('sdM')?.spectralClass).toBe('M');
|
||||
expect(parseSpectralClass('gK5')?.spectralClass).toBe('K');
|
||||
});
|
||||
|
||||
it('takes the warmer end of a range', () => {
|
||||
expect(parseSpectralClass('k-m')?.spectralClass).toBe('K');
|
||||
expect(parseSpectralClass('g-k')?.spectralClass).toBe('G');
|
||||
});
|
||||
|
||||
it('tolerates uncertainty flags and luminosity suffixes', () => {
|
||||
expect(parseSpectralClass('K:')).toEqual({ spectralClass: 'K', subclass: 0 });
|
||||
expect(parseSpectralClass('K5 V')).toEqual({ spectralClass: 'K', subclass: 5 });
|
||||
expect(parseSpectralClass('m+')).toEqual({ spectralClass: 'M', subclass: 0 });
|
||||
});
|
||||
|
||||
it('returns null when there is no recognisable class', () => {
|
||||
for (const input of ['', ' ', '...', undefined, null]) {
|
||||
expect(parseSpectralClass(input)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores an out-of-range subclass rather than trusting it', () => {
|
||||
expect(parseSpectralClass('M42')).toEqual({ spectralClass: 'M', subclass: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('spectralTypeToColorIndex', () => {
|
||||
it('places the Sun near its real B-V of 0.65', () => {
|
||||
expect(spectralTypeToColorIndex('G2V')).toBeCloseTo(0.626, 2);
|
||||
});
|
||||
|
||||
it('makes hot classes blue (negative) and cool classes red (positive)', () => {
|
||||
expect(spectralTypeToColorIndex('O5')).toBeLessThan(0);
|
||||
expect(spectralTypeToColorIndex('B0')).toBeLessThan(0);
|
||||
expect(spectralTypeToColorIndex('M5')).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('increases monotonically from hot to cool across the sequence', () => {
|
||||
const values = SPECTRAL_CLASSES.map((spectralClass) => spectralTypeToColorIndex(spectralClass)!);
|
||||
expect([...values].sort((a, b) => a - b)).toEqual(values);
|
||||
});
|
||||
|
||||
it('interpolates between class anchors by subclass', () => {
|
||||
const g0 = spectralTypeToColorIndex('G0')!;
|
||||
const g5 = spectralTypeToColorIndex('G5')!;
|
||||
const k0 = spectralTypeToColorIndex('K0')!;
|
||||
|
||||
expect(g5).toBeGreaterThan(g0);
|
||||
expect(g5).toBeLessThan(k0);
|
||||
expect(g5).toBeCloseTo((g0 + k0) / 2, 6);
|
||||
});
|
||||
|
||||
it('keeps the coolest subclasses inside a sane range', () => {
|
||||
const m9 = spectralTypeToColorIndex('M9')!;
|
||||
expect(m9).toBeGreaterThan(spectralTypeToColorIndex('M0')!);
|
||||
expect(m9).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('returns null for an unclassified star', () => {
|
||||
expect(spectralTypeToColorIndex('Unknown')).toBeNull();
|
||||
expect(spectralTypeToColorIndex('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Spectral classification helpers.
|
||||
*
|
||||
* HYG leaves the B-V colour index blank for ~10% of nearby stars, but usually still records
|
||||
* *some* spectral classification. Since colour index is only used to tint a star on screen, a
|
||||
* class-derived approximation is far better than showing those stars in a default colour that
|
||||
* happens to mean "hot and blue-white".
|
||||
*/
|
||||
|
||||
/** Harvard spectral classes, hottest to coolest. */
|
||||
export const SPECTRAL_CLASSES = ['O', 'B', 'A', 'F', 'G', 'K', 'M'] as const;
|
||||
|
||||
export type SpectralClass = (typeof SPECTRAL_CLASSES)[number];
|
||||
|
||||
/**
|
||||
* Representative main-sequence B-V colour index at subclass 0 of each class, plus a terminal
|
||||
* anchor past M9 so the coolest subclasses have something to interpolate toward. Standard
|
||||
* textbook values; precise enough for a colour tint, not for photometry.
|
||||
*/
|
||||
const COLOR_INDEX_ANCHORS: Readonly<Record<SpectralClass, number>> = {
|
||||
O: -0.33,
|
||||
B: -0.3,
|
||||
A: 0.0,
|
||||
F: 0.3,
|
||||
G: 0.58,
|
||||
K: 0.81,
|
||||
M: 1.4
|
||||
};
|
||||
|
||||
/** B-V at the cool end of class M, used as the upper interpolation bound. */
|
||||
const BEYOND_M = 2.0;
|
||||
|
||||
/**
|
||||
* Optional lowercase luminosity prefix (`d` dwarf, `sd` subdwarf, `g` giant, `c` supergiant),
|
||||
* stripped only when a class letter follows it immediately. The guard matters for `g-k`, where
|
||||
* the leading `g` is the class G opening a range rather than a giant prefix.
|
||||
*/
|
||||
const LUMINOSITY_PREFIX = /^(?:sd|[dgc])(?=[OBAFGKMobafgkm])/;
|
||||
|
||||
/** Class letter, optional subclass — anchored at the start of what remains. */
|
||||
const SPECTRAL_CLASS_PATTERN = /^([OBAFGKM])\s*(\d+(?:\.\d+)?)?/;
|
||||
|
||||
/**
|
||||
* Pulls the spectral class and (optional) numeric subclass out of a catalog string.
|
||||
*
|
||||
* HYG's `spect` column is inconsistent — `M3.5`, a bare lowercase `m`, `K5 V`, `dM4` with a
|
||||
* luminosity prefix, `K:` flagged uncertain, `k-m` for a range. Rather than trying to parse a
|
||||
* grammar that does not exist, this strips any luminosity prefix and reads the class off the
|
||||
* front. For a range like `k-m` that yields the warmer end, which is the conventional reading.
|
||||
*
|
||||
* The match is anchored rather than a free scan of the string. Scanning looks tempting and is
|
||||
* wrong: the ETL writes the literal `Unknown` for unclassified stars, and that contains a `K`,
|
||||
* so a scan silently classifies every unclassified star as an orange K-type.
|
||||
*/
|
||||
export function parseSpectralClass(
|
||||
spectralType: string | null | undefined
|
||||
): { spectralClass: SpectralClass; subclass: number } | null {
|
||||
const trimmed = (spectralType ?? '').trim().replace(LUMINOSITY_PREFIX, '');
|
||||
const match = SPECTRAL_CLASS_PATTERN.exec(trimmed.toUpperCase());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const spectralClass = match[1] as SpectralClass;
|
||||
const parsed = match[2] === undefined ? 0 : Number(match[2]);
|
||||
// Subclasses run 0-9; anything else is a misparse, so fall back to the class midpoint.
|
||||
const subclass = Number.isFinite(parsed) && parsed >= 0 && parsed < 10 ? parsed : 0;
|
||||
|
||||
return { spectralClass, subclass };
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate B-V colour index for a spectral type, interpolating between the class anchors by
|
||||
* subclass. Returns `null` when no class can be recognised, which is the honest answer for the
|
||||
* stars HYG leaves entirely unclassified.
|
||||
*/
|
||||
export function spectralTypeToColorIndex(spectralType: string | null | undefined): number | null {
|
||||
const parsed = parseSpectralClass(spectralType);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { spectralClass, subclass } = parsed;
|
||||
const index = SPECTRAL_CLASSES.indexOf(spectralClass);
|
||||
const from = COLOR_INDEX_ANCHORS[spectralClass];
|
||||
const to = index === SPECTRAL_CLASSES.length - 1 ? BEYOND_M : COLOR_INDEX_ANCHORS[SPECTRAL_CLASSES[index + 1]];
|
||||
|
||||
return from + (to - from) * (subclass / 10);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { raDegDecDistanceToXyz } from './coordinates';
|
||||
import { StarRecord } from '../models/star.model';
|
||||
import { directionCosine, isSameStar, mergeStarCatalogues } from './star-merge';
|
||||
|
||||
/** A star at a given sky position and distance, which is how catalogues actually report them. */
|
||||
function at(id: number, raDeg: number, decDeg: number, distancePc: number, overrides: Partial<StarRecord> = {}): StarRecord {
|
||||
const { x, y, z } = raDegDecDistanceToXyz(raDeg, decDeg, distancePc);
|
||||
return { id, name: `star-${id}`, x, y, z, magnitude: 5, spectralType: 'G2V', colorIndex: 0.6, ...overrides };
|
||||
}
|
||||
|
||||
const HIPPARCOS = { sourceId: 'hyg', parallaxPrecisionMas: 1 };
|
||||
const GAIA = { sourceId: 'gaia', parallaxPrecisionMas: 0.02 };
|
||||
|
||||
describe('isSameStar', () => {
|
||||
it('matches two catalogues reporting the same star', () => {
|
||||
expect(isSameStar(at(1, 101.28, -16.71, 2.64), at(2, 101.28, -16.71, 2.63))).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates the distance disagreement two parallaxes actually have', () => {
|
||||
// Hipparcos and Gaia routinely differ by tens of per cent at a few hundred parsecs. That
|
||||
// disagreement is the reason to prefer one of them, not evidence they are different stars.
|
||||
expect(isSameStar(at(1, 200, 10, 200), at(2, 200, 10, 260))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match two different stars that happen to be at the same distance', () => {
|
||||
expect(isSameStar(at(1, 200, 10, 200), at(2, 200.5, 10, 200))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not match along a line of sight when the distances genuinely conflict', () => {
|
||||
// Same direction, one three times further away: a background star, not the same object.
|
||||
expect(isSameStar(at(1, 200, 10, 100), at(2, 200, 10, 300))).toBe(false);
|
||||
});
|
||||
|
||||
it('matches on direction rather than on 3D proximity', () => {
|
||||
// The distinction the merge rests on. These two are 60 pc apart in space and are the same
|
||||
// star; a 3D-proximity test would have to be so loose it swallowed real neighbours.
|
||||
const a = at(1, 45, 20, 200);
|
||||
const b = at(2, 45, 20, 260);
|
||||
expect(Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z)).toBeGreaterThan(50);
|
||||
expect(isSameStar(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats two stars at the origin as the same, and one at the origin as unlike any other', () => {
|
||||
const origin: StarRecord = { id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.65 };
|
||||
expect(isSameStar(origin, { ...origin, id: 1 })).toBe(true);
|
||||
expect(isSameStar(origin, at(2, 45, 20, 10))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('directionCosine', () => {
|
||||
it('is one for the same direction and stays inside the domain of acos', () => {
|
||||
expect(directionCosine(at(1, 45, 20, 5), at(2, 45, 20, 500))).toBeCloseTo(1, 12);
|
||||
expect(Math.abs(directionCosine(at(1, 45, 20, 5), at(2, 225, -20, 5)))).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeStarCatalogues', () => {
|
||||
it('keeps the better-measured catalogue where two overlap', () => {
|
||||
// Gaia's parallax is fifty times more precise, so where both have a star, its position is
|
||||
// Gaia's — regardless of which catalogue was passed first.
|
||||
const shared = { raDeg: 101.28, decDeg: -16.71 };
|
||||
const { stars, summary } = mergeStarCatalogues([
|
||||
{ ...HIPPARCOS, stars: [at(1, shared.raDeg, shared.decDeg, 2.7)] },
|
||||
{ ...GAIA, stars: [at(2, shared.raDeg, shared.decDeg, 2.64)] }
|
||||
]);
|
||||
|
||||
expect(stars).toHaveLength(1);
|
||||
expect(stars[0].id).toBe(2);
|
||||
expect(stars[0].source).toBe('gaia');
|
||||
expect(summary.duplicates).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps a star the better catalogue does not reach', () => {
|
||||
// The point of merging rather than replacing: Gaia is more precise but not a superset of
|
||||
// everything, and a bright star it omits should not vanish from the map.
|
||||
const { stars } = mergeStarCatalogues([
|
||||
{ ...HIPPARCOS, stars: [at(1, 10, 10, 100)] },
|
||||
{ ...GAIA, stars: [at(2, 200, -30, 50)] }
|
||||
]);
|
||||
|
||||
expect(stars.map((star) => star.id).sort()).toEqual([1, 2]);
|
||||
expect(stars.find((star) => star.id === 1)?.source).toBe('hyg');
|
||||
});
|
||||
|
||||
it('records where every star came from', () => {
|
||||
const { stars, summary } = mergeStarCatalogues([
|
||||
{ ...HIPPARCOS, stars: [at(1, 10, 10, 100), at(3, 20, 10, 100)] },
|
||||
{ ...GAIA, stars: [at(2, 200, -30, 50)] }
|
||||
]);
|
||||
|
||||
expect(summary.bySource).toEqual({ hyg: 2, gaia: 1 });
|
||||
expect(new Set(stars.map((star) => star.source))).toEqual(new Set(['hyg', 'gaia']));
|
||||
});
|
||||
|
||||
it('does not depend on the order the catalogues were given in', () => {
|
||||
const shared = [at(1, 30, 5, 80)];
|
||||
const better = [at(2, 30, 5, 79)];
|
||||
const forwards = mergeStarCatalogues([{ ...HIPPARCOS, stars: shared }, { ...GAIA, stars: better }]);
|
||||
const backwards = mergeStarCatalogues([{ ...GAIA, stars: better }, { ...HIPPARCOS, stars: shared }]);
|
||||
|
||||
expect(forwards.stars.map((s) => s.id)).toEqual(backwards.stars.map((s) => s.id));
|
||||
});
|
||||
|
||||
it('leaves a star that already names its source alone', () => {
|
||||
const { stars } = mergeStarCatalogues([{ ...GAIA, stars: [at(1, 10, 10, 100, { source: 'gaia-dr4' })] }]);
|
||||
expect(stars[0].source).toBe('gaia-dr4');
|
||||
});
|
||||
|
||||
it('finds duplicates that straddle a sky-grid boundary', () => {
|
||||
// The bucketing is an optimisation, and an optimisation that changes the answer is a bug.
|
||||
// Every one of these sits on or beside a cell edge.
|
||||
for (const [raDeg, decDeg] of [
|
||||
[0, 0],
|
||||
[0.5, 0.5],
|
||||
[359.999, -0.0001],
|
||||
[180, 89.9]
|
||||
]) {
|
||||
const { stars } = mergeStarCatalogues([
|
||||
{ ...HIPPARCOS, stars: [at(1, raDeg, decDeg, 100)] },
|
||||
{ ...GAIA, stars: [at(2, raDeg, decDeg, 100)] }
|
||||
]);
|
||||
expect(stars).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles a single catalogue as a plain pass-through', () => {
|
||||
const { stars, summary } = mergeStarCatalogues([{ ...HIPPARCOS, stars: [at(1, 10, 10, 100), at(2, 20, 20, 100)] }]);
|
||||
expect(stars).toHaveLength(2);
|
||||
expect(summary.duplicates).toBe(0);
|
||||
});
|
||||
|
||||
it('handles no catalogues at all', () => {
|
||||
expect(mergeStarCatalogues([]).stars).toEqual([]);
|
||||
});
|
||||
|
||||
it('scales to catalogues large enough to matter', () => {
|
||||
// The reason for the sky grid: the naive pairwise merge is quadratic, and these surveys are
|
||||
// the size where that stops being an academic point.
|
||||
const many = Array.from({ length: 20000 }, (_, i) => at(i, (i * 0.017) % 360, ((i * 0.031) % 160) - 80, 100));
|
||||
const started = Date.now();
|
||||
const { stars } = mergeStarCatalogues([{ ...HIPPARCOS, stars: many }, { ...GAIA, stars: many.map((s) => ({ ...s, id: s.id + 100000 })) }]);
|
||||
|
||||
expect(stars).toHaveLength(20000);
|
||||
expect(Date.now() - started).toBeLessThan(10000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { StarRecord } from '../models/star.model';
|
||||
|
||||
/**
|
||||
* Merges star catalogues that overlap.
|
||||
*
|
||||
* Every all-sky survey contains the bright stars, so unioning two catalogues without matching
|
||||
* them first would draw Sirius twice — in slightly different places, since two instruments never
|
||||
* agree exactly. The merge therefore has to decide when two rows are the same object, and which
|
||||
* of them to believe.
|
||||
*
|
||||
* Identity is decided on the sky rather than in space. Two catalogues agree closely on a star's
|
||||
* *direction* — it is an angle, measured directly — and disagree much more on its *distance*,
|
||||
* which comes from a parallax with real error bars. Matching on 3D proximity would therefore
|
||||
* fail exactly where the catalogues are most useful: a star at 200 pc with a 25% distance
|
||||
* disagreement is 50 pc from itself, while its direction is identical to within an arcsecond.
|
||||
*/
|
||||
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
|
||||
/** Angular separation, in degrees, below which two entries are taken to be the same star. */
|
||||
export const MERGE_ANGULAR_TOLERANCE_DEG = 1 / 3600;
|
||||
|
||||
/**
|
||||
* How far two distances may disagree, as a ratio, and still describe the same star. Generous on
|
||||
* purpose: Hipparcos and Gaia routinely differ by tens of per cent at a few hundred parsecs, and
|
||||
* that disagreement is the *reason* to prefer one, not evidence they are different objects.
|
||||
*/
|
||||
export const MERGE_DISTANCE_RATIO_TOLERANCE = 0.5;
|
||||
|
||||
export interface MergeCandidate {
|
||||
readonly sourceId: string;
|
||||
/** Lower is better — the parallax precision this source measures with, in milliarcseconds. */
|
||||
readonly parallaxPrecisionMas: number;
|
||||
readonly stars: readonly StarRecord[];
|
||||
}
|
||||
|
||||
export interface MergeSummary {
|
||||
readonly total: number;
|
||||
/** Entries dropped because a better-measured catalogue already had that star. */
|
||||
readonly duplicates: number;
|
||||
readonly bySource: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
/** Unit direction of a star, which is the quantity catalogues actually agree on. */
|
||||
function direction(star: StarRecord): [number, number, number] {
|
||||
const length = Math.hypot(star.x, star.y, star.z);
|
||||
return length === 0 ? [0, 0, 0] : [star.x / length, star.y / length, star.z / length];
|
||||
}
|
||||
|
||||
function distanceOf(star: StarRecord): number {
|
||||
return Math.hypot(star.x, star.y, star.z);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buckets a direction onto a coarse sky grid, so a star only has to be compared against the
|
||||
* handful of entries near it rather than against every star already merged.
|
||||
*
|
||||
* The cell is much larger than the match tolerance, so a pair straddling a boundary would be
|
||||
* missed — which is why {@link neighbouringCells} checks the adjacent cells too.
|
||||
*/
|
||||
const SKY_CELL_DEG = 0.5;
|
||||
|
||||
function cellKey(raDeg: number, decDeg: number): string {
|
||||
return `${Math.floor(raDeg / SKY_CELL_DEG)}:${Math.floor(decDeg / SKY_CELL_DEG)}`;
|
||||
}
|
||||
|
||||
function skyAngles(star: StarRecord): { raDeg: number; decDeg: number } {
|
||||
const [x, y, z] = direction(star);
|
||||
return { raDeg: (Math.atan2(y, x) / DEG_TO_RAD + 360) % 360, decDeg: Math.asin(Math.max(-1, Math.min(1, z))) / DEG_TO_RAD };
|
||||
}
|
||||
|
||||
function neighbouringCells(raDeg: number, decDeg: number): string[] {
|
||||
const keys: string[] = [];
|
||||
for (let dRa = -1; dRa <= 1; dRa++) {
|
||||
for (let dDec = -1; dDec <= 1; dDec++) {
|
||||
keys.push(cellKey(raDeg + dRa * SKY_CELL_DEG, decDeg + dDec * SKY_CELL_DEG));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** Cosine of the angle between two stars' directions. */
|
||||
export function directionCosine(a: StarRecord, b: StarRecord): number {
|
||||
const [ax, ay, az] = direction(a);
|
||||
const [bx, by, bz] = direction(b);
|
||||
return Math.max(-1, Math.min(1, ax * bx + ay * by + az * bz));
|
||||
}
|
||||
|
||||
/** Whether two entries describe the same star: same direction, and distances not in conflict. */
|
||||
export function isSameStar(a: StarRecord, b: StarRecord): boolean {
|
||||
const [near, far] = [distanceOf(a), distanceOf(b)].sort((p, q) => p - q);
|
||||
|
||||
// The Sun sits at the origin of this coordinate system and so has no direction at all, which
|
||||
// the angular test below cannot speak about. Every catalogue contains it, so without this the
|
||||
// merge would happily keep one Sun per source.
|
||||
if (near === 0) {
|
||||
return far === 0;
|
||||
}
|
||||
|
||||
const separationDeg = Math.acos(directionCosine(a, b)) / DEG_TO_RAD;
|
||||
if (separationDeg > MERGE_ANGULAR_TOLERANCE_DEG) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (far - near) / near <= MERGE_DISTANCE_RATIO_TOLERANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unions the given catalogues, keeping one entry per star.
|
||||
*
|
||||
* Sources are taken in order of how precisely they measure parallax, best first, and a star is
|
||||
* only added if no better-measured catalogue already has it. So where Gaia and Hipparcos
|
||||
* overlap, the position is Gaia's; where only Hipparcos reaches, the star is still there.
|
||||
*/
|
||||
export function mergeStarCatalogues(candidates: readonly MergeCandidate[]): { stars: StarRecord[]; summary: MergeSummary } {
|
||||
const ordered = [...candidates].sort((a, b) => a.parallaxPrecisionMas - b.parallaxPrecisionMas);
|
||||
const merged: StarRecord[] = [];
|
||||
const grid = new Map<string, StarRecord[]>();
|
||||
const bySource: Record<string, number> = {};
|
||||
let duplicates = 0;
|
||||
|
||||
for (const candidate of ordered) {
|
||||
bySource[candidate.sourceId] = 0;
|
||||
|
||||
for (const star of candidate.stars) {
|
||||
const { raDeg, decDeg } = skyAngles(star);
|
||||
const alreadyPresent = neighbouringCells(raDeg, decDeg).some((key) => (grid.get(key) ?? []).some((existing) => isSameStar(existing, star)));
|
||||
|
||||
if (alreadyPresent) {
|
||||
duplicates++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const withSource: StarRecord = { ...star, source: star.source ?? candidate.sourceId };
|
||||
merged.push(withSource);
|
||||
bySource[candidate.sourceId]++;
|
||||
|
||||
const key = cellKey(raDeg, decDeg);
|
||||
const cell = grid.get(key);
|
||||
if (cell) {
|
||||
cell.push(withSource);
|
||||
} else {
|
||||
grid.set(key, [withSource]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { stars: merged, summary: { total: merged.length, duplicates, bySource } };
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { absoluteMagnitude, bolometricCorrection, luminositySolar, SOLAR_ABSOLUTE_MAGNITUDE_V, SOLAR_BOLOMETRIC_MAGNITUDE } from './stellar';
|
||||
|
||||
/** Real catalogue rows, with the published luminosity each one should reproduce. */
|
||||
const SIRIUS = { magnitude: -1.44, distancePc: 2.6371, spectralType: 'A0m...', publishedLuminosity: 25.4 };
|
||||
const VEGA = { magnitude: 0.03, distancePc: 7.68, spectralType: 'A0Vvar', publishedLuminosity: 40 };
|
||||
const PROXIMA = { magnitude: 11.01, distancePc: 1.2959, spectralType: 'M5Ve', publishedLuminosity: 0.0015 };
|
||||
const ALPHA_CEN_A = { magnitude: -0.01, distancePc: 1.3247, spectralType: 'G2V', publishedLuminosity: 1.52 };
|
||||
|
||||
describe('absoluteMagnitude', () => {
|
||||
it('is the apparent magnitude at the reference distance of ten parsecs', () => {
|
||||
expect(absoluteMagnitude(5, 10)).toBeCloseTo(5, 12);
|
||||
});
|
||||
|
||||
it('brightens a star as it is placed further away for the same apparent magnitude', () => {
|
||||
expect(absoluteMagnitude(5, 100)).toBeLessThan(absoluteMagnitude(5, 10)!);
|
||||
});
|
||||
|
||||
it('reproduces the published absolute magnitude of Sirius', () => {
|
||||
expect(absoluteMagnitude(SIRIUS.magnitude, SIRIUS.distancePc)).toBeCloseTo(1.45, 1);
|
||||
});
|
||||
|
||||
it('has no answer at zero distance, which in this catalogue is the Sun', () => {
|
||||
expect(absoluteMagnitude(-26.7, 0)).toBeNull();
|
||||
expect(absoluteMagnitude(5, -3)).toBeNull();
|
||||
expect(absoluteMagnitude(Number.NaN, 10)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bolometricCorrection', () => {
|
||||
it('is never positive: a star always radiates outside the V band as well as in it', () => {
|
||||
for (const type of ['O5V', 'B2V', 'A0V', 'F5V', 'G2V', 'K5V', 'M5V', 'M9V', 'Unknown', '']) {
|
||||
expect(bolometricCorrection(type)).toBeLessThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('is small for the Sun and large for a red dwarf, which is the whole reason it is applied', () => {
|
||||
// An M dwarf emits most of its light in the infrared: taking its V magnitude at face value
|
||||
// understates it by more than a factor of ten.
|
||||
expect(Math.abs(bolometricCorrection('G2V'))).toBeLessThan(0.2);
|
||||
expect(bolometricCorrection('M5V')).toBeLessThan(-2);
|
||||
});
|
||||
|
||||
it('reproduces the Sun own correction closely enough to close the loop on the zero point', () => {
|
||||
// The two solar magnitudes differ by exactly this correction, so a solar twin must come out
|
||||
// at one solar luminosity.
|
||||
expect(SOLAR_ABSOLUTE_MAGNITUDE_V + bolometricCorrection('G2V')).toBeCloseTo(SOLAR_BOLOMETRIC_MAGNITUDE, 1);
|
||||
});
|
||||
|
||||
it('deepens monotonically from F through M, following the shift into the infrared', () => {
|
||||
const sequence = ['F0V', 'G0V', 'K0V', 'M0V', 'M5V'].map((type) => bolometricCorrection(type));
|
||||
for (let index = 1; index < sequence.length; index++) {
|
||||
expect(sequence[index]).toBeLessThan(sequence[index - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to a solar correction for an unclassified star rather than inventing one', () => {
|
||||
expect(bolometricCorrection('Unknown')).toBeCloseTo(bolometricCorrection('G0V'), 6);
|
||||
expect(bolometricCorrection(undefined)).toBeCloseTo(bolometricCorrection('G0V'), 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('luminositySolar', () => {
|
||||
it('returns exactly one for the Sun, which defines the unit', () => {
|
||||
expect(luminositySolar({ magnitude: -26.7, distancePc: 0, spectralType: 'G2V' })).toBe(1);
|
||||
});
|
||||
|
||||
it('lands within a factor of two of the published luminosity for real stars', () => {
|
||||
// The documented tolerance. It is looser than it sounds: equilibrium temperature goes as the
|
||||
// fourth root of this, so a factor of two is under a fifth in temperature.
|
||||
for (const star of [SIRIUS, VEGA, PROXIMA, ALPHA_CEN_A]) {
|
||||
const derived = luminositySolar(star)!;
|
||||
const ratio = derived / star.publishedLuminosity;
|
||||
expect(ratio).toBeGreaterThan(0.5);
|
||||
expect(ratio).toBeLessThan(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('gets a solar analogue essentially exactly right', () => {
|
||||
// Alpha Centauri A is the nearest star to a second Sun there is, so this is the case where
|
||||
// an error would be a mistake rather than a tolerance.
|
||||
expect(luminositySolar(ALPHA_CEN_A)!).toBeCloseTo(ALPHA_CEN_A.publishedLuminosity, 0);
|
||||
});
|
||||
|
||||
it('orders stars the way their published luminosities do', () => {
|
||||
const derived = [PROXIMA, ALPHA_CEN_A, SIRIUS, VEGA].map((star) => luminositySolar(star)!);
|
||||
for (let index = 1; index < derived.length; index++) {
|
||||
expect(derived[index]).toBeGreaterThan(derived[index - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('applies the bolometric correction rather than taking V at face value', () => {
|
||||
// Without it a red dwarf comes out more than ten times too dim.
|
||||
const uncorrected = Math.pow(10, (SOLAR_BOLOMETRIC_MAGNITUDE - absoluteMagnitude(PROXIMA.magnitude, PROXIMA.distancePc)!) / 2.5);
|
||||
expect(luminositySolar(PROXIMA)!).toBeGreaterThan(uncorrected * 5);
|
||||
});
|
||||
|
||||
it('clamps a pathological record instead of producing an absurd luminosity', () => {
|
||||
const absurd = luminositySolar({ magnitude: -40, distancePc: 5000, spectralType: 'O5V' })!;
|
||||
expect(Number.isFinite(absurd)).toBe(true);
|
||||
expect(absurd).toBeLessThanOrEqual(1e7);
|
||||
});
|
||||
|
||||
it('has no answer for a star with no usable distance', () => {
|
||||
expect(luminositySolar({ magnitude: 5, distancePc: -1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { parseSpectralClass, SpectralClass } from './spectral';
|
||||
|
||||
/**
|
||||
* Stellar luminosity, derived from the two things the star catalogue actually measures.
|
||||
*
|
||||
* Nothing here is a published luminosity: HYG carries apparent magnitude and a parallax, and
|
||||
* the Exoplanet Archive columns that would give a host star's mass or effective temperature are
|
||||
* not in the shipped dataset. What those two measurements do give, exactly, is absolute
|
||||
* magnitude — and from there the bolometric correction below turns a V-band brightness into a
|
||||
* total energy output, which is what a planet's temperature actually depends on.
|
||||
*/
|
||||
|
||||
/** The Sun's absolute magnitude in V — what the distance modulus below is measured against. */
|
||||
export const SOLAR_ABSOLUTE_MAGNITUDE_V = 4.83;
|
||||
|
||||
/**
|
||||
* The Sun's absolute *bolometric* magnitude, the IAU 2015 zero point. Distinct from the V-band
|
||||
* figure above by the Sun's own bolometric correction, and it is the one the ratio is taken
|
||||
* against — mixing the two would leave every luminosity 9% high.
|
||||
*/
|
||||
export const SOLAR_BOLOMETRIC_MAGNITUDE = 4.74;
|
||||
|
||||
/**
|
||||
* Bolometric corrections for main-sequence stars, at subclass 0 of each class (Pecaut & Mamajek
|
||||
* 2013, rounded). Always negative: a star radiates outside the V band as well as in it, so its
|
||||
* total output always exceeds what a visual magnitude alone implies.
|
||||
*
|
||||
* The correction matters most exactly where it is largest. An M dwarf emits the bulk of its
|
||||
* light in the infrared, so taking its V magnitude at face value understates it by more than a
|
||||
* factor of ten — and M dwarfs are what most of the nearby planet hosts are.
|
||||
*/
|
||||
const BOLOMETRIC_CORRECTION_ANCHORS: Readonly<Record<SpectralClass, number>> = {
|
||||
O: -4.0,
|
||||
B: -3.0,
|
||||
A: -0.25,
|
||||
F: -0.01,
|
||||
G: -0.06,
|
||||
K: -0.24,
|
||||
M: -1.21
|
||||
};
|
||||
|
||||
/** Correction at the cool end of class M, so the latest subclasses interpolate toward it. */
|
||||
const BEYOND_M_CORRECTION = -4.6;
|
||||
|
||||
/**
|
||||
* Range the derived luminosity is clamped to, in solar luminosities.
|
||||
*
|
||||
* A guard against the one systematic error this method cannot detect on its own: the
|
||||
* corrections above assume a main-sequence star, and HYG often records a spectral class with no
|
||||
* luminosity class at all. A red giant read as a K dwarf comes out hundreds of times too
|
||||
* bright, which is a large error but not an unbounded one — these bounds simply keep a
|
||||
* pathological record from producing a temperature of a million kelvin.
|
||||
*/
|
||||
const MIN_LUMINOSITY_SOLAR = 1e-6;
|
||||
const MAX_LUMINOSITY_SOLAR = 1e7;
|
||||
|
||||
/**
|
||||
* Absolute magnitude from apparent magnitude and distance — the distance modulus.
|
||||
*
|
||||
* Returns `null` for a star at zero distance, which in this catalogue means the Sun: its
|
||||
* apparent magnitude of -26.7 is a statement about how close it is, not about how bright it is,
|
||||
* and the formula has no answer there.
|
||||
*/
|
||||
export function absoluteMagnitude(apparentMagnitude: number, distancePc: number): number | null {
|
||||
if (!Number.isFinite(apparentMagnitude) || !Number.isFinite(distancePc) || distancePc <= 0) {
|
||||
return null;
|
||||
}
|
||||
return apparentMagnitude - 5 * Math.log10(distancePc) + 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bolometric correction for a spectral type, interpolated between the class anchors. Falls back
|
||||
* to the solar value when the catalogue records no usable classification, which biases a
|
||||
* misclassified red dwarf dim rather than inventing a correction for it.
|
||||
*/
|
||||
export function bolometricCorrection(spectralType: string | null | undefined): number {
|
||||
const parsed = parseSpectralClass(spectralType);
|
||||
if (!parsed) {
|
||||
return BOLOMETRIC_CORRECTION_ANCHORS.G;
|
||||
}
|
||||
|
||||
const { spectralClass, subclass } = parsed;
|
||||
const classes = Object.keys(BOLOMETRIC_CORRECTION_ANCHORS) as SpectralClass[];
|
||||
const index = classes.indexOf(spectralClass);
|
||||
const from = BOLOMETRIC_CORRECTION_ANCHORS[spectralClass];
|
||||
const to = index < classes.length - 1 ? BOLOMETRIC_CORRECTION_ANCHORS[classes[index + 1]] : BEYOND_M_CORRECTION;
|
||||
const t = Math.min(Math.max(subclass, 0), 10) / 10;
|
||||
|
||||
return from + (to - from) * t;
|
||||
}
|
||||
|
||||
/** Everything about a star that bears on how much light it puts out. */
|
||||
export interface StellarPhotometry {
|
||||
/** Apparent visual magnitude, as catalogued. */
|
||||
magnitude: number;
|
||||
/** Distance from the Sun in parsecs; `0` identifies the Sun itself. */
|
||||
distancePc: number;
|
||||
spectralType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total luminosity in solar units.
|
||||
*
|
||||
* The Sun is returned as exactly 1 rather than derived — it is the definition of the unit, and
|
||||
* it is the one star whose distance in this catalogue is zero.
|
||||
*
|
||||
* Accurate to roughly a factor of two for main-sequence stars, which is better than it sounds
|
||||
* for what it is used for: a planet's equilibrium temperature goes as the fourth root of this,
|
||||
* so even a factor of two moves a temperature by less than a fifth.
|
||||
*/
|
||||
export function luminositySolar(star: StellarPhotometry): number | null {
|
||||
if (star.distancePc === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const absolute = absoluteMagnitude(star.magnitude, star.distancePc);
|
||||
if (absolute === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bolometric = absolute + bolometricCorrection(star.spectralType);
|
||||
const luminosity = Math.pow(10, (SOLAR_BOLOMETRIC_MAGNITUDE - bolometric) / 2.5);
|
||||
return Math.min(Math.max(luminosity, MIN_LUMINOSITY_SOLAR), MAX_LUMINOSITY_SOLAR);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Broad visual category a deep-sky object is grouped under on the galaxy-view backdrop. */
|
||||
export type DeepSkyKind = 'galaxy' | 'nebula' | 'cluster';
|
||||
|
||||
/** How a record's distance estimate was derived, when one could be derived at all. */
|
||||
export type DeepSkyDistanceMethod = 'parallax' | 'redshift';
|
||||
|
||||
/**
|
||||
* A notable deep-sky object (nebula, star cluster or galaxy) from the OpenNGC catalog,
|
||||
* rendered as the galaxy view's backdrop.
|
||||
*
|
||||
* **Why a direction and not a position.** Every other record in this app carries a Cartesian
|
||||
* position in parsecs, but deep-sky objects deliberately do not. The star field spans 50 pc;
|
||||
* the nearest object here is several hundred parsecs away and the galaxies are millions. At
|
||||
* true scale they would all sit far outside the galaxy camera's far plane, so a position in
|
||||
* parsecs would be unusable for the backdrop it exists to draw.
|
||||
*
|
||||
* More importantly the distances mostly are not knowable from this catalog: OpenNGC publishes
|
||||
* no distance column, so it has to be inferred from redshift or parallax, and that inference
|
||||
* fails for exactly the best-known objects — M31, M33 and M42 are all Local Group members whose
|
||||
* redshift is negative (they are approaching us) or absent. What *is* always known, and known
|
||||
* precisely, is the line of sight. So the position here is a unit vector on the celestial
|
||||
* sphere and {@link distancePc} is optional metadata.
|
||||
*/
|
||||
export interface DeepSkyRecord {
|
||||
/** OpenNGC designation, e.g. `NGC0224`. Stable, and unique within the catalog. */
|
||||
id: string;
|
||||
/** Best available display name: common name, else Messier number, else the designation. */
|
||||
name: string;
|
||||
kind: DeepSkyKind;
|
||||
/**
|
||||
* Unit vector toward the object, in the same equatorial J2000 frame as `StarRecord`
|
||||
* (+X toward the vernal equinox, +Z toward the north celestial pole). Not a position —
|
||||
* see the note on this interface.
|
||||
*/
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
/** Apparent major-axis size on the sky, in degrees. */
|
||||
angularSizeDeg: number;
|
||||
/** Apparent visual magnitude (falling back to blue), or `null` when unphotometered. */
|
||||
magnitude: number | null;
|
||||
/** Estimated distance in parsecs, or `null` when it could not be derived. */
|
||||
distancePc: number | null;
|
||||
/** Provenance for {@link distancePc}; `null` whenever the distance is `null`. */
|
||||
distanceMethod: DeepSkyDistanceMethod | null;
|
||||
/** IAU constellation abbreviation, e.g. `And`. */
|
||||
constellation: string;
|
||||
/** Messier designation, e.g. `M31`, when the object has one. */
|
||||
messier: string | null;
|
||||
}
|
||||
@@ -12,5 +12,26 @@ export interface ExoplanetRecord {
|
||||
radiusEarth?: number;
|
||||
massEarth?: number;
|
||||
discoveryYear?: number;
|
||||
/**
|
||||
* Measured orbital period in days (`pl_orbper`). Together with the semi-major axis this
|
||||
* pins the host star's gravitational parameter exactly, so the planet can be propagated at
|
||||
* its real rate instead of as though it orbited the Sun — see `resolveGravitationalParameter`.
|
||||
*/
|
||||
periodDays?: number;
|
||||
/** Host star mass in solar masses (`st_mass`); the fallback when no period is published. */
|
||||
hostStarMassSolar?: number;
|
||||
/**
|
||||
* The host star's own published position (`ra`, `dec`, `sy_dist`) — the coordinates the
|
||||
* cross-reference above is resolved from.
|
||||
*
|
||||
* Kept rather than consumed and discarded. `hostStarId` is the *result* of a match against
|
||||
* whatever star catalogue was loaded at the time, so widening that catalogue ought to rescue
|
||||
* some of the 4347 hosts that currently resolve to nothing — but with only the result stored,
|
||||
* redoing the match meant re-downloading the archive. These three numbers make it a local
|
||||
* operation. See `rematchHostStars`.
|
||||
*/
|
||||
hostRaDeg?: number;
|
||||
hostDecDeg?: number;
|
||||
hostDistancePc?: number;
|
||||
orbit: Partial<OrbitalElements>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog } from './star-catalog';
|
||||
import { StarRecord } from './star.model';
|
||||
|
||||
const STARS: StarRecord[] = [
|
||||
{ id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 },
|
||||
{ id: 71456, name: 'Rigil Kentaurus', x: -1.35, y: -0.04, z: -0.98, magnitude: -0.01, spectralType: 'G2V', colorIndex: 0.71 },
|
||||
{ id: 32263, name: 'Sirius', x: -0.49, y: 2.47, z: -0.75, magnitude: -1.44, spectralType: 'A0m...', colorIndex: 0.009 },
|
||||
// The case a plain number cannot carry: about a tenth of the catalogue was never photometered.
|
||||
{ id: 118554, name: 'GJ 3512', x: 20.1, y: -3.4, z: 8.8, magnitude: 15, spectralType: 'Unknown', colorIndex: null }
|
||||
];
|
||||
|
||||
describe('encodeStarCatalog / decodeStarCatalog', () => {
|
||||
const encoded = encodeStarCatalog(STARS);
|
||||
const decoded = decodeStarCatalog(encoded.index, encoded.positions, encoded.meta);
|
||||
|
||||
it('round-trips every field of every star', () => {
|
||||
expect(decoded).toHaveLength(STARS.length);
|
||||
decoded.forEach((star, index) => {
|
||||
const original = STARS[index];
|
||||
expect(star.id).toBe(original.id);
|
||||
expect(star.name).toBe(original.name);
|
||||
expect(star.spectralType).toBe(original.spectralType);
|
||||
expect(star.x).toBeCloseTo(original.x, 4);
|
||||
expect(star.y).toBeCloseTo(original.y, 4);
|
||||
expect(star.z).toBeCloseTo(original.z, 4);
|
||||
expect(star.magnitude).toBeCloseTo(original.magnitude, 4);
|
||||
});
|
||||
});
|
||||
|
||||
it('carries an absent colour index through as null, not as zero', () => {
|
||||
// Zero is a real colour index meaning a hot blue-white A-type star, so it cannot double as
|
||||
// "not measured" — the float column uses NaN, which nothing else can be.
|
||||
expect(decoded[3].colorIndex).toBeNull();
|
||||
expect(decoded[0].colorIndex).toBeCloseTo(0.656, 5);
|
||||
expect(decoded[2].colorIndex).toBeCloseTo(0.009, 5);
|
||||
});
|
||||
|
||||
it('sizes both binaries exactly to the star count', () => {
|
||||
expect(encoded.positions.byteLength).toBe(STARS.length * BYTES_PER_STAR_POSITION);
|
||||
expect(encoded.meta.byteLength).toBe(STARS.length * BYTES_PER_STAR_META);
|
||||
});
|
||||
|
||||
it('hands positions over as a bare xyz buffer, which is what the GPU is given', () => {
|
||||
expect(Array.from(encoded.positions.slice(0, 3))).toEqual([0, 0, 0]);
|
||||
expect(encoded.positions[3]).toBeCloseTo(-1.35, 4);
|
||||
});
|
||||
|
||||
it('stores each distinct spectral type once and refers to it by index', () => {
|
||||
// Two of the four stars are G2V. Across the real catalogue this is 68000 stars sharing
|
||||
// about 2600 strings, which is why the dictionary is worth having.
|
||||
expect(encoded.index.spectralTypes).toEqual(['G2V', 'A0m...', 'Unknown']);
|
||||
});
|
||||
|
||||
it('keeps the index free of anything that is not a string, since the numbers are elsewhere', () => {
|
||||
expect(encoded.index.count).toBe(STARS.length);
|
||||
expect(encoded.index.names).toEqual(STARS.map((star) => star.name));
|
||||
});
|
||||
|
||||
it('writes no per-star source column when every star came from the same place', () => {
|
||||
// It would be a couple of hundred kilobytes to say nothing.
|
||||
expect(encoded.index.sourceIndices).toEqual([]);
|
||||
});
|
||||
|
||||
it('is smaller than the array of objects it replaced', () => {
|
||||
// The whole reason for the format: the old encoding repeated eight key names per star.
|
||||
const asObjects = JSON.stringify(STARS).length;
|
||||
const asCatalogue = JSON.stringify(encoded.index).length + encoded.positions.byteLength + encoded.meta.byteLength;
|
||||
expect(asCatalogue).toBeLessThan(asObjects);
|
||||
});
|
||||
|
||||
it('handles an empty catalogue without producing a malformed buffer', () => {
|
||||
const empty = encodeStarCatalog([]);
|
||||
expect(empty.positions.byteLength).toBe(0);
|
||||
expect(empty.meta.byteLength).toBe(0);
|
||||
expect(decodeStarCatalog(empty.index, empty.positions, empty.meta)).toEqual([]);
|
||||
});
|
||||
|
||||
it('survives more distinct spectral types than a handful, up to the column width', () => {
|
||||
// The dictionary index is 16-bit, and the real catalogue has about 2600 distinct types.
|
||||
const many: StarRecord[] = Array.from({ length: 5000 }, (_, i) => ({
|
||||
id: i,
|
||||
name: `HYG ${i}`,
|
||||
x: i,
|
||||
y: 0,
|
||||
z: 0,
|
||||
magnitude: 10,
|
||||
spectralType: `S${i}`,
|
||||
colorIndex: null
|
||||
}));
|
||||
const round = decodeStarCatalog(...(({ index, positions, meta }) => [index, positions, meta] as const)(encodeStarCatalog(many)));
|
||||
|
||||
expect(round[4999].spectralType).toBe('S4999');
|
||||
expect(round[4999].id).toBe(4999);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('star catalogue provenance and derived names', () => {
|
||||
const MIXED: StarRecord[] = [
|
||||
{ id: 5, name: 'Sirius', x: 1, y: 0, z: 0, magnitude: -1.4, spectralType: 'A0', colorIndex: 0.0, source: 'hyg' },
|
||||
// A survey star with no name of its own: what it is called is its catalogue designation.
|
||||
{ id: 900, name: 'Gaia DR3 900', x: 0, y: 2, z: 0, magnitude: 11, spectralType: 'Unknown', colorIndex: 1.2, source: 'gaia' },
|
||||
{ id: 901, name: 'Gaia DR3 901', x: 0, y: 0, z: 3, magnitude: 11.5, spectralType: 'Unknown', colorIndex: 1.3, source: 'gaia' }
|
||||
];
|
||||
|
||||
const encoded = encodeStarCatalog(MIXED);
|
||||
const decoded = decodeStarCatalog(encoded.index, encoded.positions, encoded.meta);
|
||||
|
||||
it('stores nothing for a name that is just the catalogue designation', () => {
|
||||
// 25 bytes per star, per million stars, to repeat what two adjacent fields already say.
|
||||
expect(encoded.index.names).toEqual(['Sirius', '', '']);
|
||||
});
|
||||
|
||||
it('regenerates those names exactly on the way back', () => {
|
||||
expect(decoded.map((star) => star.name)).toEqual(['Sirius', 'Gaia DR3 900', 'Gaia DR3 901']);
|
||||
});
|
||||
|
||||
it('carries each star provenance through', () => {
|
||||
expect(decoded.map((star) => star.source)).toEqual(['hyg', 'gaia', 'gaia']);
|
||||
});
|
||||
|
||||
it('writes the source column only once the stars differ', () => {
|
||||
expect(encoded.index.sources.map((source) => source.id)).toEqual(['hyg', 'gaia']);
|
||||
expect(encoded.index.sourceIndices).toEqual([0, 1, 1]);
|
||||
});
|
||||
|
||||
it('keeps a real name even when the star has a source that could generate one', () => {
|
||||
const named = encodeStarCatalog([{ ...MIXED[1], name: 'Some Proper Name' }]);
|
||||
expect(named.index.names).toEqual(['Some Proper Name']);
|
||||
expect(decodeStarCatalog(named.index, named.positions, named.meta)[0].name).toBe('Some Proper Name');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { StarRecord } from './star.model';
|
||||
|
||||
/**
|
||||
* On-disk format for the star catalogue, shared by the ETL that writes it and the app that
|
||||
* reads it so the two cannot drift apart.
|
||||
*
|
||||
* The catalogue outgrew a plain array of JSON objects. At the 50 pc cutoff it held 8750 stars
|
||||
* and cost 157 bytes each — most of that the same eight key names repeated once per star. At
|
||||
* the distance Hipparcos parallaxes actually reach, that same encoding would have been about
|
||||
* 17 MB of JSON to parse before the first frame.
|
||||
*
|
||||
* So the numbers move to a binary column store and the strings stay in JSON, where the two
|
||||
* repetitive ones — spectral types, of which 68000 stars share about 2600 distinct values —
|
||||
* collapse into a dictionary. The result is roughly a quarter of the size for eight times the
|
||||
* stars, and the numeric columns arrive as typed arrays with no parsing at all.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Positions stay in their own file rather than joining the columns below.
|
||||
*
|
||||
* They are the one column handed to the GPU verbatim: `StarFieldRenderer` binds the buffer
|
||||
* straight from `stars.bin` as an instanced attribute, so keeping it a bare `Float32Array` of
|
||||
* xyz triples means the star field costs one fetch and no repacking.
|
||||
*/
|
||||
export const STAR_POSITION_COMPONENTS = 3;
|
||||
export const BYTES_PER_STAR_POSITION = STAR_POSITION_COMPONENTS * Float32Array.BYTES_PER_ELEMENT;
|
||||
|
||||
/**
|
||||
* Columns in `stars-meta.bin`, in order: catalogue id, apparent magnitude, colour index, and an
|
||||
* index into the spectral-type dictionary. Stored column by column rather than record by record
|
||||
* so each one is a single typed-array view over the buffer, with no per-record stride or
|
||||
* alignment padding.
|
||||
*/
|
||||
export const BYTES_PER_STAR_META =
|
||||
Int32Array.BYTES_PER_ELEMENT + Float32Array.BYTES_PER_ELEMENT + Float32Array.BYTES_PER_ELEMENT + Uint16Array.BYTES_PER_ELEMENT;
|
||||
|
||||
/** `stars-index.json`: everything that is a string, plus the count the columns are sized by. */
|
||||
export interface StarCatalogIndex {
|
||||
count: number;
|
||||
/**
|
||||
* One per star, in catalogue order — but empty where the name is simply the star's catalogue
|
||||
* designation, which is regenerated on load from the source and the id.
|
||||
*
|
||||
* A survey-scale catalogue has no proper names to speak of. Gaia's designations are its
|
||||
* 19-digit source ids, so writing "Gaia DR3 4472832130942575872" once per star would cost
|
||||
* 25 MB per million stars — more than the whole rest of the catalogue — to store a string that
|
||||
* is already implied by two fields next to it. An empty entry costs three bytes.
|
||||
*
|
||||
* Dense with holes rather than a list of pairs, because which one is smaller depends entirely
|
||||
* on the catalogue: every HYG star has a designation worth keeping, and paying an index per
|
||||
* entry to say so would be 60% larger than just writing them in order.
|
||||
*/
|
||||
names: string[];
|
||||
/** Distinct spectral classifications; the meta column holds indices into this. */
|
||||
spectralTypes: string[];
|
||||
/**
|
||||
* Distinct source ids, in the same dictionary form as the spectral types, plus the designation
|
||||
* prefix each one names its unnamed stars with.
|
||||
*/
|
||||
sources: { id: string; designationPrefix: string }[];
|
||||
/**
|
||||
* One per star: an index into `sources`. Empty when every star came from the same place, which
|
||||
* would otherwise cost a couple of hundred kilobytes to say nothing.
|
||||
*/
|
||||
sourceIndices: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* How a source names a star that has no name of its own. `Gaia DR3 <id>` for Gaia; HYG's own
|
||||
* fallbacks already produce real designations, so it never needs this.
|
||||
*/
|
||||
const DESIGNATION_PREFIXES: Readonly<Record<string, string>> = {
|
||||
gaia: 'Gaia DR3'
|
||||
};
|
||||
|
||||
interface StarMetaColumns {
|
||||
ids: Int32Array;
|
||||
magnitudes: Float32Array;
|
||||
colorIndices: Float32Array;
|
||||
spectralTypeIndices: Uint16Array;
|
||||
}
|
||||
|
||||
/** Lays typed-array views over the meta buffer at the offsets the format defines. */
|
||||
function metaColumns(buffer: ArrayBuffer, count: number): StarMetaColumns {
|
||||
let offset = 0;
|
||||
const ids = new Int32Array(buffer, offset, count);
|
||||
offset += count * Int32Array.BYTES_PER_ELEMENT;
|
||||
const magnitudes = new Float32Array(buffer, offset, count);
|
||||
offset += count * Float32Array.BYTES_PER_ELEMENT;
|
||||
const colorIndices = new Float32Array(buffer, offset, count);
|
||||
offset += count * Float32Array.BYTES_PER_ELEMENT;
|
||||
const spectralTypeIndices = new Uint16Array(buffer, offset, count);
|
||||
|
||||
return { ids, magnitudes, colorIndices, spectralTypeIndices };
|
||||
}
|
||||
|
||||
/**
|
||||
* Packs the string and numeric halves of a star list into the two files the app loads.
|
||||
*
|
||||
* `colorIndex` is genuinely nullable — about a tenth of the catalogue was never photometered —
|
||||
* and `NaN` carries that through the float column. It is the one value a float can hold that
|
||||
* means "no measurement" without colliding with a real one, and 0 emphatically does not: it is
|
||||
* a real colour index meaning a hot blue-white A-type star.
|
||||
*/
|
||||
export function encodeStarCatalog(stars: readonly StarRecord[]): {
|
||||
index: StarCatalogIndex;
|
||||
positions: Float32Array;
|
||||
meta: ArrayBuffer;
|
||||
} {
|
||||
const count = stars.length;
|
||||
const positions = new Float32Array(count * STAR_POSITION_COMPONENTS);
|
||||
const meta = new ArrayBuffer(count * BYTES_PER_STAR_META);
|
||||
const columns = metaColumns(meta, count);
|
||||
|
||||
const spectralTypes: string[] = [];
|
||||
const spectralTypeIds = new Map<string, number>();
|
||||
const names: string[] = [];
|
||||
const sources: { id: string; designationPrefix: string }[] = [];
|
||||
const sourceIds = new Map<string, number>();
|
||||
const sourceIndices: number[] = [];
|
||||
|
||||
stars.forEach((star, index) => {
|
||||
positions[index * 3] = star.x;
|
||||
positions[index * 3 + 1] = star.y;
|
||||
positions[index * 3 + 2] = star.z;
|
||||
|
||||
let sourceIndex = -1;
|
||||
if (star.source !== undefined) {
|
||||
const known = sourceIds.get(star.source);
|
||||
if (known === undefined) {
|
||||
sourceIndex = sources.push({ id: star.source, designationPrefix: DESIGNATION_PREFIXES[star.source] ?? star.source }) - 1;
|
||||
sourceIds.set(star.source, sourceIndex);
|
||||
} else {
|
||||
sourceIndex = known;
|
||||
}
|
||||
}
|
||||
sourceIndices.push(sourceIndex);
|
||||
|
||||
// Left empty when it is simply the designation the source would generate anyway.
|
||||
names.push(star.name === designationFor(sources[sourceIndex]?.designationPrefix, star.id) ? '' : star.name);
|
||||
|
||||
let spectralTypeId = spectralTypeIds.get(star.spectralType);
|
||||
if (spectralTypeId === undefined) {
|
||||
spectralTypeId = spectralTypes.push(star.spectralType) - 1;
|
||||
spectralTypeIds.set(star.spectralType, spectralTypeId);
|
||||
}
|
||||
|
||||
columns.ids[index] = star.id;
|
||||
columns.magnitudes[index] = star.magnitude;
|
||||
columns.colorIndices[index] = star.colorIndex ?? Number.NaN;
|
||||
columns.spectralTypeIndices[index] = spectralTypeId;
|
||||
});
|
||||
|
||||
// A per-star column is only worth writing when the stars actually differ.
|
||||
const mixedSources = sources.length > 1;
|
||||
return { index: { count, names, spectralTypes, sources, sourceIndices: mixedSources ? sourceIndices : [] }, positions, meta };
|
||||
}
|
||||
|
||||
/** The name a source gives a star it has no other name for. */
|
||||
function designationFor(prefix: string | undefined, id: number): string | undefined {
|
||||
return prefix === undefined ? undefined : `${prefix} ${id}`;
|
||||
}
|
||||
|
||||
/** Rebuilds the star records the app works with from the three loaded assets. */
|
||||
export function decodeStarCatalog(index: StarCatalogIndex, positions: Float32Array, meta: ArrayBuffer): StarRecord[] {
|
||||
const columns = metaColumns(meta, index.count);
|
||||
const stars: StarRecord[] = new Array(index.count);
|
||||
|
||||
for (let i = 0; i < index.count; i++) {
|
||||
const colorIndex = columns.colorIndices[i];
|
||||
const id = columns.ids[i];
|
||||
const sourceIndex = index.sourceIndices.length > 0 ? index.sourceIndices[i] : index.sources.length === 1 ? 0 : -1;
|
||||
const source = index.sources[sourceIndex];
|
||||
|
||||
stars[i] = {
|
||||
id,
|
||||
name: index.names[i] || designationFor(source?.designationPrefix, id) || `HYG ${id}`,
|
||||
x: positions[i * 3],
|
||||
y: positions[i * 3 + 1],
|
||||
z: positions[i * 3 + 2],
|
||||
magnitude: columns.magnitudes[i],
|
||||
spectralType: index.spectralTypes[columns.spectralTypeIndices[i]],
|
||||
colorIndex: Number.isNaN(colorIndex) ? null : colorIndex,
|
||||
...(source ? { source: source.id } : {})
|
||||
};
|
||||
}
|
||||
|
||||
return stars;
|
||||
}
|
||||
@@ -12,7 +12,20 @@ export interface StarRecord {
|
||||
z: number;
|
||||
magnitude: number;
|
||||
spectralType: string;
|
||||
colorIndex: number;
|
||||
/**
|
||||
* B-V colour index, or `null` where the catalog has no photometry — about 10% of stars
|
||||
* within the distance cutoff. Deliberately nullable rather than defaulted: `0` is a real,
|
||||
* meaningful colour index (a hot blue-white A-type star), so using it to stand for "unknown"
|
||||
* silently mis-colours those stars. Consumers resolve the gap from `spectralType`; see
|
||||
* `colorIndexToRgb`.
|
||||
*/
|
||||
colorIndex: number | null;
|
||||
/**
|
||||
* Which catalogue this star's position came from, once more than one contributes. Absent for a
|
||||
* single-source build; see `star-merge.ts`, where overlapping catalogues are reconciled and
|
||||
* the better-measured parallax wins.
|
||||
*/
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/** HYG id used for the Sun itself, so solar-system bodies can reference their host star. */
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { PlanetAppearance, PlanetClass, paletteFor, planetAppearance } from '../astro/planet-appearance';
|
||||
import { averageColor, planetTexture, renderPlanetTexture } from './procedural-planet-texture';
|
||||
|
||||
const SIZE = { width: 64, height: 32 };
|
||||
const ALL_CLASSES: PlanetClass[] = ['lava', 'scorched', 'iron', 'rocky', 'temperate', 'icy', 'subNeptune', 'iceGiant', 'gasGiant', 'hotGasGiant'];
|
||||
|
||||
function appearanceOf(planetClass: PlanetClass, overrides: Partial<PlanetAppearance> = {}): PlanetAppearance {
|
||||
return {
|
||||
planetClass,
|
||||
palette: paletteFor(planetClass),
|
||||
equilibriumTemperatureK: 250,
|
||||
bulkDensityGramsPerCm3: 5,
|
||||
polarCapExtentDeg: null,
|
||||
seed: 12345,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/** RGB of one texel, 0-255. */
|
||||
function texelAt(pixels: Uint8Array, width: number, column: number, row: number): [number, number, number] {
|
||||
const offset = (row * width + column) * 4;
|
||||
return [pixels[offset], pixels[offset + 1], pixels[offset + 2]];
|
||||
}
|
||||
|
||||
function difference(a: readonly number[], b: readonly number[]): number {
|
||||
return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]);
|
||||
}
|
||||
|
||||
describe('renderPlanetTexture', () => {
|
||||
it('fills an opaque RGBA buffer of the requested size', () => {
|
||||
const pixels = renderPlanetTexture(appearanceOf('rocky'), SIZE);
|
||||
|
||||
expect(pixels).toHaveLength(SIZE.width * SIZE.height * 4);
|
||||
for (let index = 3; index < pixels.length; index += 4) {
|
||||
expect(pixels[index]).toBe(255);
|
||||
}
|
||||
});
|
||||
|
||||
it('is the same surface every time, so a world does not change between visits', () => {
|
||||
const first = renderPlanetTexture(appearanceOf('gasGiant'), SIZE);
|
||||
const second = renderPlanetTexture(appearanceOf('gasGiant'), SIZE);
|
||||
expect(Array.from(second)).toEqual(Array.from(first));
|
||||
});
|
||||
|
||||
it('gives two different worlds two different surfaces', () => {
|
||||
const a = renderPlanetTexture(appearanceOf('rocky', { seed: 1 }), SIZE);
|
||||
const b = renderPlanetTexture(appearanceOf('rocky', { seed: 2 }), SIZE);
|
||||
expect(Array.from(a)).not.toEqual(Array.from(b));
|
||||
});
|
||||
|
||||
it('wraps continuously around the seam, since the noise is sampled on the sphere', () => {
|
||||
// The reason for sampling a solid field along the sphere rather than a plane: 2D noise would
|
||||
// have to be stitched at this seam by hand, and would still pinch at the poles.
|
||||
const pixels = renderPlanetTexture(appearanceOf('rocky'), { width: 256, height: 128 });
|
||||
for (const row of [10, 64, 120]) {
|
||||
const left = texelAt(pixels, 256, 0, row);
|
||||
const right = texelAt(pixels, 256, 255, row);
|
||||
const neighbouring = texelAt(pixels, 256, 1, row);
|
||||
// The two edge columns are neighbours on the sphere, so they must differ no more than any
|
||||
// other adjacent pair does.
|
||||
expect(difference(left, right)).toBeLessThanOrEqual(difference(left, neighbouring) + 12);
|
||||
}
|
||||
});
|
||||
|
||||
it('varies with latitude, which is what makes a banded world banded', () => {
|
||||
const pixels = renderPlanetTexture(appearanceOf('gasGiant'), { width: 128, height: 64 });
|
||||
const column = 40;
|
||||
let maximumStep = 0;
|
||||
for (let row = 1; row < 64; row++) {
|
||||
maximumStep = Math.max(maximumStep, difference(texelAt(pixels, 128, column, row), texelAt(pixels, 128, column, row - 1)));
|
||||
}
|
||||
expect(maximumStep).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('paints a polar cap when the derived temperature calls for one, and not otherwise', () => {
|
||||
const withCap = renderPlanetTexture(appearanceOf('temperate', { polarCapExtentDeg: 40 }), SIZE);
|
||||
const without = renderPlanetTexture(appearanceOf('temperate', { polarCapExtentDeg: null }), SIZE);
|
||||
|
||||
const pole = 0;
|
||||
const equator = SIZE.height / 2;
|
||||
// At the pole the capped world is markedly brighter; at the equator the two agree.
|
||||
const capPole = texelAt(withCap, SIZE.width, 10, pole);
|
||||
const barePole = texelAt(without, SIZE.width, 10, pole);
|
||||
expect(capPole[0] + capPole[1] + capPole[2]).toBeGreaterThan(barePole[0] + barePole[1] + barePole[2] + 60);
|
||||
expect(difference(texelAt(withCap, SIZE.width, 10, equator), texelAt(without, SIZE.width, 10, equator))).toBe(0);
|
||||
});
|
||||
|
||||
it('grows the cap further toward the equator as the world gets colder', () => {
|
||||
const brightnessAt = (extent: number, row: number): number => {
|
||||
const pixels = renderPlanetTexture(appearanceOf('temperate', { polarCapExtentDeg: extent }), SIZE);
|
||||
const [r, g, b] = texelAt(pixels, SIZE.width, 20, row);
|
||||
return r + g + b;
|
||||
};
|
||||
const midLatitude = 6;
|
||||
expect(brightnessAt(80, midLatitude)).toBeGreaterThan(brightnessAt(20, midLatitude));
|
||||
});
|
||||
|
||||
it('draws a banded world and a terrain world differently from the same seed', () => {
|
||||
const banded = renderPlanetTexture(appearanceOf('gasGiant'), SIZE);
|
||||
const terrain = renderPlanetTexture(appearanceOf('rocky'), SIZE);
|
||||
expect(Array.from(banded)).not.toEqual(Array.from(terrain));
|
||||
});
|
||||
|
||||
it('keeps a hot giant red and an ice giant blue, end to end', () => {
|
||||
const hot = averageColor(renderPlanetTexture(appearanceOf('hotGasGiant'), SIZE));
|
||||
const ice = averageColor(renderPlanetTexture(appearanceOf('iceGiant'), SIZE));
|
||||
|
||||
expect(hot.r).toBeGreaterThan(hot.b);
|
||||
expect(ice.b).toBeGreaterThan(ice.r);
|
||||
});
|
||||
|
||||
it('produces no NaN or out-of-range bytes for any class', () => {
|
||||
for (const planetClass of ALL_CLASSES) {
|
||||
const pixels = renderPlanetTexture(appearanceOf(planetClass, { polarCapExtentDeg: 30 }), SIZE);
|
||||
for (const value of pixels) {
|
||||
expect(Number.isInteger(value)).toBe(true);
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
expect(value).toBeLessThanOrEqual(255);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('planetTexture', () => {
|
||||
it('builds a data texture at the requested size, with no canvas involved', () => {
|
||||
// A DataTexture rather than a CanvasTexture: the pixels are computed, not drawn, so this
|
||||
// works in an environment with no 2D context at all — which is this one.
|
||||
const texture = planetTexture(planetAppearance({ id: 'earth', radiusEarth: 1, massEarth: 1, semiMajorAxisAu: 1, hostLuminositySolar: 1 }), SIZE);
|
||||
|
||||
expect(texture.image.width).toBe(SIZE.width);
|
||||
expect(texture.image.height).toBe(SIZE.height);
|
||||
expect(texture.image.data).toHaveLength(SIZE.width * SIZE.height * 4);
|
||||
});
|
||||
|
||||
it('caches per body and size, so a system of planets is not re-rendered every frame', () => {
|
||||
const appearance = planetAppearance({ id: 'mars', radiusEarth: 0.53, semiMajorAxisAu: 1.52, hostLuminositySolar: 1 });
|
||||
expect(planetTexture(appearance, SIZE)).toBe(planetTexture(appearance, SIZE));
|
||||
expect(planetTexture(appearance, SIZE)).not.toBe(planetTexture(appearance, { width: 32, height: 16 }));
|
||||
});
|
||||
|
||||
it('wraps in longitude and clamps in latitude, matching what the sphere actually does', () => {
|
||||
const texture = planetTexture(appearanceOf('icy'), SIZE);
|
||||
expect(texture.wrapS).toBe(THREE.RepeatWrapping);
|
||||
expect(texture.wrapT).toBe(THREE.ClampToEdgeWrapping);
|
||||
});
|
||||
});
|
||||
|
||||
describe('averageColor', () => {
|
||||
it('averages a uniform buffer to that colour', () => {
|
||||
const pixels = new Uint8Array(16);
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
pixels.set([255, 128, 0, 255], index);
|
||||
}
|
||||
const average = averageColor(pixels);
|
||||
expect(average.r).toBeCloseTo(1, 6);
|
||||
expect(average.g).toBeCloseTo(128 / 255, 6);
|
||||
expect(average.b).toBeCloseTo(0, 6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
|
||||
import { PlanetAppearance, Rgb } from '../astro/planet-appearance';
|
||||
|
||||
/**
|
||||
* Paints an equirectangular surface for a world from its derived appearance.
|
||||
*
|
||||
* Two things shape it, and both come out of the physics rather than out of taste. A body with a
|
||||
* fluid envelope and no surface gets zonal *bands*, because a rapidly rotating atmosphere
|
||||
* organises into them — that is why Jupiter looks the way it does. A body with a solid surface
|
||||
* gets *terrain*, fractal highlands and basins, because that is what an impacted, eroded crust
|
||||
* looks like at planetary scale. The polar caps then grow and shrink with the derived
|
||||
* equilibrium temperature.
|
||||
*
|
||||
* Written against a plain `Uint8Array` rather than a canvas, which makes it a pure function:
|
||||
* fully testable with no DOM, no 2D context to be unavailable, and no per-pixel draw calls.
|
||||
*/
|
||||
|
||||
/** Size for the body-detail view, where the surface fills the screen. */
|
||||
export const DETAIL_TEXTURE_WIDTH = 512;
|
||||
export const DETAIL_TEXTURE_HEIGHT = 256;
|
||||
/**
|
||||
* Size for a system-view marker, which is a few pixels across. Deliberately tiny: a system can
|
||||
* hold twenty bodies and they are all generated at once as the camera arrives, so this is the
|
||||
* size at which that whole set costs less than a frame.
|
||||
*/
|
||||
export const MARKER_TEXTURE_WIDTH = 32;
|
||||
export const MARKER_TEXTURE_HEIGHT = 16;
|
||||
|
||||
const TERRAIN_OCTAVES = 4;
|
||||
const ROUGHNESS_OCTAVES = 3;
|
||||
const BAND_TURBULENCE_OCTAVES = 3;
|
||||
/** Zonal bands per hemisphere, varied a little per body so no two giants are identical. */
|
||||
const MIN_BANDS = 7;
|
||||
const MAX_BANDS = 14;
|
||||
|
||||
/** Hash of three lattice coordinates and a seed to a value in [0, 1). */
|
||||
function hash3(x: number, y: number, z: number, seed: number): number {
|
||||
let h = seed ^ Math.imul(x | 0, 374761393) ^ Math.imul(y | 0, 668265263) ^ Math.imul(z | 0, 2147483647);
|
||||
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
||||
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
/** Hermite fade, so the interpolated field has no visible lattice creases. */
|
||||
function fade(t: number): number {
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Value noise sampled in three dimensions.
|
||||
*
|
||||
* Three rather than two on purpose: the texture is equirectangular, so 2D noise would have to
|
||||
* be made to wrap by hand at the seam and would still pinch at the poles. Sampling a solid
|
||||
* field along the sphere's own surface has neither problem — the field is continuous
|
||||
* everywhere the sphere is.
|
||||
*/
|
||||
function valueNoise3(x: number, y: number, z: number, seed: number): number {
|
||||
const xi = Math.floor(x);
|
||||
const yi = Math.floor(y);
|
||||
const zi = Math.floor(z);
|
||||
const xf = fade(x - xi);
|
||||
const yf = fade(y - yi);
|
||||
const zf = fade(z - zi);
|
||||
|
||||
const c000 = hash3(xi, yi, zi, seed);
|
||||
const c100 = hash3(xi + 1, yi, zi, seed);
|
||||
const c010 = hash3(xi, yi + 1, zi, seed);
|
||||
const c110 = hash3(xi + 1, yi + 1, zi, seed);
|
||||
const c001 = hash3(xi, yi, zi + 1, seed);
|
||||
const c101 = hash3(xi + 1, yi, zi + 1, seed);
|
||||
const c011 = hash3(xi, yi + 1, zi + 1, seed);
|
||||
const c111 = hash3(xi + 1, yi + 1, zi + 1, seed);
|
||||
|
||||
const x00 = c000 + (c100 - c000) * xf;
|
||||
const x10 = c010 + (c110 - c010) * xf;
|
||||
const x01 = c001 + (c101 - c001) * xf;
|
||||
const x11 = c011 + (c111 - c011) * xf;
|
||||
const y0 = x00 + (x10 - x00) * yf;
|
||||
const y1 = x01 + (x11 - x01) * yf;
|
||||
|
||||
return y0 + (y1 - y0) * zf;
|
||||
}
|
||||
|
||||
/** Fractal Brownian motion: octaves of value noise at doubling frequency, halving amplitude. */
|
||||
function fbm(x: number, y: number, z: number, seed: number, octaves: number): number {
|
||||
let amplitude = 1;
|
||||
let frequency = 1;
|
||||
let sum = 0;
|
||||
let total = 0;
|
||||
|
||||
for (let octave = 0; octave < octaves; octave++) {
|
||||
sum += amplitude * valueNoise3(x * frequency, y * frequency, z * frequency, seed + octave * 7919);
|
||||
total += amplitude;
|
||||
amplitude *= 0.5;
|
||||
frequency *= 2;
|
||||
}
|
||||
|
||||
return sum / total;
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return value < 0 ? 0 : value > 1 ? 1 : value;
|
||||
}
|
||||
|
||||
function mix(a: Rgb, b: Rgb, t: number): [number, number, number] {
|
||||
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
||||
}
|
||||
|
||||
/** Three-stop ramp across the palette's low, mid and high tones. */
|
||||
function ramp(appearance: PlanetAppearance, t: number): [number, number, number] {
|
||||
const { low, mid, high } = appearance.palette;
|
||||
const clamped = clamp01(t);
|
||||
return clamped < 0.5 ? mix(low, mid, clamped * 2) : mix(mid, high, (clamped - 0.5) * 2);
|
||||
}
|
||||
|
||||
/** Pulls a value toward or away from the midpoint, by the palette's contrast. */
|
||||
function applyContrast(value: number, contrast: number): number {
|
||||
return clamp01(0.5 + (value - 0.5) * (0.4 + contrast * 1.2));
|
||||
}
|
||||
|
||||
export interface PlanetTextureSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the surface into an RGBA byte array, row-major from the north pole down, ready to
|
||||
* hand to a `DataTexture`.
|
||||
*/
|
||||
export function renderPlanetTexture(appearance: PlanetAppearance, size: PlanetTextureSize): Uint8Array {
|
||||
const { width, height } = size;
|
||||
const pixels = new Uint8Array(width * height * 4);
|
||||
const { palette, seed } = appearance;
|
||||
const banded = palette.structure === 'banded';
|
||||
// Band count is stable per body but not identical between bodies, so a system of giants does
|
||||
// not read as the same planet drawn several times.
|
||||
const bandCount = MIN_BANDS + (seed % (MAX_BANDS - MIN_BANDS + 1));
|
||||
const capExtent = appearance.polarCapExtentDeg;
|
||||
|
||||
for (let row = 0; row < height; row++) {
|
||||
// Texel centres, so the poles are sampled just inside the surface rather than exactly on it.
|
||||
const v = (row + 0.5) / height;
|
||||
const latitude = (0.5 - v) * Math.PI;
|
||||
const cosLatitude = Math.cos(latitude);
|
||||
const sinLatitude = Math.sin(latitude);
|
||||
|
||||
for (let column = 0; column < width; column++) {
|
||||
const u = (column + 0.5) / width;
|
||||
const longitude = u * Math.PI * 2;
|
||||
// The point on the unit sphere this texel maps to — the noise is sampled there.
|
||||
const px = cosLatitude * Math.cos(longitude);
|
||||
const py = sinLatitude;
|
||||
const pz = cosLatitude * Math.sin(longitude);
|
||||
|
||||
let tone: number;
|
||||
if (banded) {
|
||||
// Latitude, pushed around by turbulence, then folded into zonal bands. The turbulence is
|
||||
// what makes a belt wander and braid rather than sit as a perfect stripe.
|
||||
const turbulence = fbm(px * 2.2, py * 2.2, pz * 2.2, seed, BAND_TURBULENCE_OCTAVES) - 0.5;
|
||||
// Stretched along longitude and squeezed in latitude, which is what shear does to a
|
||||
// cloud: the streaks run round the planet rather than across it.
|
||||
const fine = fbm(px * 6, py * 30, pz * 6, seed + 101, 3) - 0.5;
|
||||
const warped = latitude + turbulence * 0.32 + fine * 0.05;
|
||||
tone = 0.5 + 0.5 * Math.sin(warped * bandCount);
|
||||
tone = tone * 0.85 + (fine + 0.5) * 0.15;
|
||||
} else {
|
||||
// Broad landmasses, then finer detail on top of them. The ridged term is the same noise
|
||||
// folded about its midpoint, which turns smooth hills into creases — the difference
|
||||
// between a surface that reads as cloud and one that reads as ground.
|
||||
const continents = fbm(px * 2.4, py * 2.4, pz * 2.4, seed, TERRAIN_OCTAVES);
|
||||
const roughness = fbm(px * 18, py * 18, pz * 18, seed + 313, ROUGHNESS_OCTAVES);
|
||||
const ridged = 1 - Math.abs(2 * roughness - 1);
|
||||
tone = continents * 0.68 + roughness * 0.18 + ridged * 0.14;
|
||||
}
|
||||
|
||||
let [r, g, b] = ramp(appearance, applyContrast(tone, palette.contrast));
|
||||
|
||||
if (capExtent !== null && capExtent > 0) {
|
||||
// Caps are ragged rather than a clean circle: the same surface noise that shapes the
|
||||
// terrain decides how far the ice reaches at each longitude.
|
||||
const latitudeFromPoleDeg = 90 - (Math.abs(latitude) * 180) / Math.PI;
|
||||
const edge = capExtent * (0.85 + 0.3 * fbm(px * 6, py * 6, pz * 6, seed + 977, 3));
|
||||
const coverage = clamp01((edge - latitudeFromPoleDeg) / Math.max(edge * 0.35, 1));
|
||||
if (coverage > 0) {
|
||||
[r, g, b] = mix([r, g, b], palette.cap, coverage);
|
||||
}
|
||||
}
|
||||
|
||||
const offset = (row * width + column) * 4;
|
||||
pixels[offset] = Math.round(clamp01(r) * 255);
|
||||
pixels[offset + 1] = Math.round(clamp01(g) * 255);
|
||||
pixels[offset + 2] = Math.round(clamp01(b) * 255);
|
||||
pixels[offset + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return pixels;
|
||||
}
|
||||
|
||||
const textureCache = new Map<string, THREE.DataTexture>();
|
||||
|
||||
/**
|
||||
* The rendered surface as a Three.js texture, cached per body and size.
|
||||
*
|
||||
* A `DataTexture` rather than a `CanvasTexture`: the pixels are computed rather than drawn, so
|
||||
* there is no reason to route them through a 2D context that may not exist — which also means
|
||||
* this works under a headless test environment where canvas rendering does not.
|
||||
*/
|
||||
export function planetTexture(appearance: PlanetAppearance, size: PlanetTextureSize = { width: DETAIL_TEXTURE_WIDTH, height: DETAIL_TEXTURE_HEIGHT }): THREE.DataTexture {
|
||||
const key = `${appearance.seed}:${appearance.planetClass}:${appearance.polarCapExtentDeg ?? 'none'}:${size.width}x${size.height}`;
|
||||
const cached = textureCache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const texture = new THREE.DataTexture(renderPlanetTexture(appearance, size), size.width, size.height, THREE.RGBAFormat);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
// Wraps in longitude — the noise is continuous across the seam — but is clamped in latitude,
|
||||
// where there is nothing beyond the pole to wrap to.
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.generateMipmaps = true;
|
||||
texture.needsUpdate = true;
|
||||
|
||||
textureCache.set(key, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
/** Average colour of a rendered surface, for anything too small to show the texture itself. */
|
||||
export function averageColor(pixels: Uint8Array): THREE.Color {
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
const count = pixels.length / 4;
|
||||
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
r += pixels[index];
|
||||
g += pixels[index + 1];
|
||||
b += pixels[index + 2];
|
||||
}
|
||||
|
||||
return new THREE.Color(r / count / 255, g / count / 255, b / count / 255);
|
||||
}
|
||||
@@ -22,12 +22,41 @@ export function applyMilkyWaySkybox(scene: THREE.Scene, path: string): void {
|
||||
const glowSpriteCache = new Map<string, THREE.Texture>();
|
||||
|
||||
/**
|
||||
* A soft radial-gradient canvas texture, cached per color, used to fake atmosphere/corona glow.
|
||||
* Returns `undefined` if 2D canvas rendering isn't available (e.g. under a test/jsdom
|
||||
* environment with no canvas backend) so callers can fall back to a flat-color sprite instead.
|
||||
* Falloff shapes for {@link createGlowTexture}.
|
||||
*
|
||||
* `corona` is a tight, bright core for a star's or planet's halo, where the light really does
|
||||
* come from a small hot source. `diffuse` is a much softer, dimmer profile for deep-sky
|
||||
* objects, which are extended clouds — the same tight curve turns them into hard-edged
|
||||
* billiard balls that read as solid geometry rather than as haze.
|
||||
*/
|
||||
function glowSpriteTexture(color: THREE.ColorRepresentation): THREE.Texture | undefined {
|
||||
const key = new THREE.Color(color).getHexString();
|
||||
export type GlowProfile = 'corona' | 'diffuse';
|
||||
|
||||
const GLOW_PROFILES: Readonly<Record<GlowProfile, readonly { offset: number; alpha: number }[]>> = {
|
||||
corona: [
|
||||
{ offset: 0, alpha: 0.85 },
|
||||
{ offset: 0.4, alpha: 0.35 },
|
||||
{ offset: 1, alpha: 0 }
|
||||
],
|
||||
diffuse: [
|
||||
{ offset: 0, alpha: 0.5 },
|
||||
{ offset: 0.18, alpha: 0.34 },
|
||||
{ offset: 0.45, alpha: 0.13 },
|
||||
{ offset: 0.75, alpha: 0.03 },
|
||||
{ offset: 1, alpha: 0 }
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* A soft radial-gradient canvas texture, cached per color and profile, used to fake
|
||||
* atmosphere/corona glow and to paint deep-sky objects on the galaxy backdrop. Returns
|
||||
* `undefined` if 2D canvas rendering isn't available (e.g. under a test/jsdom environment with
|
||||
* no canvas backend) so callers can fall back to a flat-color sprite instead.
|
||||
*
|
||||
* The cache is process-wide and intentionally not disposed: there is one texture per distinct
|
||||
* color/profile pair, they are tiny, and they outlive any individual scene.
|
||||
*/
|
||||
export function createGlowTexture(color: THREE.ColorRepresentation, profile: GlowProfile = 'corona'): THREE.Texture | undefined {
|
||||
const key = `${new THREE.Color(color).getHexString()}:${profile}`;
|
||||
const cached = glowSpriteCache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
@@ -46,9 +75,9 @@ function glowSpriteTexture(color: THREE.ColorRepresentation): THREE.Texture | un
|
||||
const [r, g, b] = [Math.round(rgb.r * 255), Math.round(rgb.g * 255), Math.round(rgb.b * 255)];
|
||||
|
||||
const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
||||
gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.85)`);
|
||||
gradient.addColorStop(0.4, `rgba(${r}, ${g}, ${b}, 0.35)`);
|
||||
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
|
||||
for (const stop of GLOW_PROFILES[profile]) {
|
||||
gradient.addColorStop(stop.offset, `rgba(${r}, ${g}, ${b}, ${stop.alpha})`);
|
||||
}
|
||||
context.fillStyle = gradient;
|
||||
context.fillRect(0, 0, size, size);
|
||||
|
||||
@@ -59,20 +88,24 @@ function glowSpriteTexture(color: THREE.ColorRepresentation): THREE.Texture | un
|
||||
|
||||
/**
|
||||
* Builds a soft additive-blended glow halo (used for planetary atmospheres and the Sun's
|
||||
* corona) sized relative to the given object radius. Cheap billboard-sprite approximation
|
||||
* rather than a view-angle-correct Fresnel shader, chosen to stay within built-in material
|
||||
* types the WebGPU backend renders natively (see plan risk on TSL/shader maturity). Falls back
|
||||
* to a flat-colored (gradient-less) sprite if canvas rendering is unavailable.
|
||||
* corona), `extent` across in world units. Cheap billboard-sprite approximation rather than a
|
||||
* view-angle-correct Fresnel shader, chosen to stay within built-in material types the WebGPU
|
||||
* backend renders natively (see plan risk on TSL/shader maturity). Falls back to a
|
||||
* flat-colored (gradient-less) sprite if canvas rendering is unavailable.
|
||||
*
|
||||
* Takes the finished extent rather than a radius and a multiplier: how big a star's halo should
|
||||
* be is not a fixed multiple of the star, it depends on how the system is framed, and that
|
||||
* decision belongs with the framing (see `starGlowExtentAu`).
|
||||
*/
|
||||
export function createGlowSprite(color: THREE.ColorRepresentation, radius: number, scale: number): THREE.Sprite {
|
||||
export function createGlowSprite(color: THREE.ColorRepresentation, extent: number): THREE.Sprite {
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: glowSpriteTexture(color),
|
||||
map: createGlowTexture(color),
|
||||
color: color,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
const sprite = new THREE.Sprite(material);
|
||||
sprite.scale.setScalar(radius * scale);
|
||||
sprite.scale.setScalar(extent);
|
||||
return sprite;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ import * as THREE from 'three/webgpu';
|
||||
|
||||
/**
|
||||
* Real NASA/ESA/USGS photography baked into `src/assets/textures/bodies/` at build time,
|
||||
* keyed by the same ids used in `bodies.json`. Bodies without an entry here (most exoplanets,
|
||||
* a few moons whose photo wasn't sourced this round, and any future body) fall back to
|
||||
* `proceduralBodyTexture()` below rather than a flat color.
|
||||
* keyed by the same ids used in `bodies.json`.
|
||||
*
|
||||
* This map is the whole of what has actually been photographed. Everything else — every
|
||||
* exoplanet, since not one has ever been imaged, and the moons no probe returned a usable map
|
||||
* of — falls through to `procedural-planet-texture.ts`, which derives a surface from the body's
|
||||
* own measured size, mass, orbit and host star instead.
|
||||
*
|
||||
* Provenance (all public domain NASA/JPL or CC BY 4.0 Solar System Scope, via Wikimedia
|
||||
* Commons — see each file's Commons page for the original credit line):
|
||||
@@ -80,61 +83,3 @@ export function loadCachedTexture(path: string): THREE.Texture {
|
||||
loadedTextures.set(path, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
const proceduralTextureCache = new Map<string, THREE.CanvasTexture>();
|
||||
|
||||
/**
|
||||
* Generates a simple procedural surface for bodies with no real photograph available — mainly
|
||||
* exoplanets, whose actual surfaces have never been directly imaged. This is an honest artistic
|
||||
* stand-in (mottled bands tinted by the body's classification color), not a fabricated "real"
|
||||
* texture, and is cached per color so repeated exoplanets of the same kind share one canvas.
|
||||
* Returns `undefined` if 2D canvas rendering isn't available (e.g. under a test/jsdom
|
||||
* environment with no canvas backend); callers should fall back to a flat material color.
|
||||
*/
|
||||
export function proceduralBodyTexture(baseColor: THREE.ColorRepresentation): THREE.CanvasTexture | undefined {
|
||||
const key = new THREE.Color(baseColor).getHexString();
|
||||
const cached = proceduralTextureCache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const size = 256;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const base = new THREE.Color(baseColor);
|
||||
const light = base.clone().offsetHSL(0, -0.15, 0.14);
|
||||
const dark = base.clone().offsetHSL(0, 0.05, -0.16);
|
||||
|
||||
context.fillStyle = `#${base.getHexString()}`;
|
||||
context.fillRect(0, 0, size, size);
|
||||
|
||||
// A handful of horizontal-ish noisy bands, reminiscent of banded gas giants / mottled rock,
|
||||
// without claiming to depict any specific real surface feature.
|
||||
let seed = key.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) || 1;
|
||||
const random = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
|
||||
const bandCount = 10;
|
||||
for (let i = 0; i < bandCount; i++) {
|
||||
const y = (i / bandCount) * size + random() * (size / bandCount) * 0.4;
|
||||
const height = size / bandCount * (0.5 + random() * 0.6);
|
||||
context.fillStyle = `#${(random() > 0.5 ? light : dark).getHexString()}`;
|
||||
context.globalAlpha = 0.35 + random() * 0.25;
|
||||
context.fillRect(0, y, size, height);
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
proceduralTextureCache.set(key, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type ViewLevel = 'galaxy' | 'system';
|
||||
/**
|
||||
* The map's zoom levels, outermost first.
|
||||
*
|
||||
* `galactic` is the whole Milky Way; `galaxy` is the catalogued solar neighbourhood inside it
|
||||
* (the level the app opens on); `system` is one star's planets. The first two share a coordinate
|
||||
* space and are told apart by how far the camera has pulled back, so the scene reports which one
|
||||
* it is in rather than being commanded into it.
|
||||
*/
|
||||
export type ViewLevel = 'galactic' | 'galaxy' | 'system';
|
||||
|
||||
/**
|
||||
* App-wide navigation state: which zoom level is active and what's currently selected.
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -26,6 +26,69 @@ body {
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
/* --- HUD chrome ------------------------------------------------------------------------- */
|
||||
|
||||
/* Angled top-left/bottom-right corners, a thin lit edge and a blurred dark fill: the panel
|
||||
* shape the whole heads-up display is built from. Written here rather than as utilities
|
||||
* because `clip-path` and the layered border need to travel together. */
|
||||
.hud-panel {
|
||||
position: relative;
|
||||
background: color-mix(in srgb, var(--color-panel) 78%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
clip-path: polygon(14px 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%, 0 14px);
|
||||
}
|
||||
|
||||
/* The lit inner hairline that reads as a bevel on the two square corners. */
|
||||
.hud-panel::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Corner brackets around the whole viewport, drawn as four gradients so the element stays a
|
||||
* single node with no children. */
|
||||
.hud-frame {
|
||||
pointer-events: none;
|
||||
--bracket: color-mix(in srgb, var(--color-accent) 45%, transparent);
|
||||
--arm-long: 76px;
|
||||
--arm-thin: 1px;
|
||||
background-image:
|
||||
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket)),
|
||||
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket)),
|
||||
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket)),
|
||||
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket));
|
||||
background-repeat: no-repeat;
|
||||
background-size:
|
||||
var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long), var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long),
|
||||
var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long), var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long);
|
||||
background-position:
|
||||
18px 18px, 18px 18px, right 18px top 18px, right 18px top 18px, 18px bottom 18px, 18px bottom 18px, right 18px bottom 18px,
|
||||
right 18px bottom 18px;
|
||||
}
|
||||
|
||||
/* Darkens the edges of the frame so the map reads as a lit display rather than a flat page. */
|
||||
.hud-vignette {
|
||||
pointer-events: none;
|
||||
background: radial-gradient(ellipse at center, transparent 45%, color-mix(in srgb, var(--color-void) 72%, transparent) 100%);
|
||||
}
|
||||
|
||||
/* A chamfered tab: the angled-corner shape the scale ladder and the selection banner share.
|
||||
* The cut is on the leading and trailing edges rather than the panel's diagonal corners, which
|
||||
* is what makes a row of them read as tabs on a rail instead of a row of cards. */
|
||||
.hud-tab {
|
||||
clip-path: polygon(9px 0, 100% 0, calc(100% - 9px) 100%, 0 100%);
|
||||
}
|
||||
|
||||
/* The selected-object banner: symmetric, flaring outward toward its lit bottom edge, so it
|
||||
* reads as a nameplate hanging off the top of the display rather than another tab. */
|
||||
.hud-banner {
|
||||
clip-path: polygon(14px 0, calc(100% - 14px) 0, 100% 100%, 0 100%);
|
||||
}
|
||||
|
||||
/* Star name labels rendered by CSS2DRenderer (see StarLabelOverlay). These live outside
|
||||
* Angular's view encapsulation as plain DOM nodes, so they're styled with Tailwind utility
|
||||
* classes assigned directly in TypeScript rather than a scoped component stylesheet. */
|
||||
@@ -34,3 +97,26 @@ body {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Two-line map label: what the thing is called, then what it is. The type line is deliberately
|
||||
* much quieter than the name — it should be readable when looked at and invisible when not,
|
||||
* because on a crowded view it is printed as many times as there are labels. */
|
||||
.map-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.15;
|
||||
text-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.map-label-name {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.map-label-kind {
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--color-accent) 55%, transparent);
|
||||
}
|
||||
|
||||
+97
-3
@@ -1,11 +1,16 @@
|
||||
import { statSync } from 'node:fs';
|
||||
|
||||
import { BodyRecord } from '../../src/app/shared/models/body.model';
|
||||
import { DeepSkyRecord } from '../../src/app/shared/models/deepsky.model';
|
||||
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
|
||||
import { StarRecord } from '../../src/app/shared/models/star.model';
|
||||
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
||||
import { fetchDeepSky } from './fetchDeepSky';
|
||||
import { fetchExoplanets } from './fetchExoplanets';
|
||||
import { fetchSolarSystem } from './fetchSolarSystem';
|
||||
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
|
||||
import { fetchStars } from './fetchStars';
|
||||
import { describeSources } from './sources/registry';
|
||||
import { rematchHostStars } from '../../src/app/shared/astro/host-star-matching';
|
||||
import { dataPath } from './lib/paths';
|
||||
|
||||
class ValidationError extends Error {}
|
||||
@@ -28,8 +33,21 @@ function validateStars(stars: StarRecord[]): void {
|
||||
assertCondition([star.x, star.y, star.z].every(Number.isFinite), `Star ${star.id} has a non-finite position.`);
|
||||
}
|
||||
|
||||
const binSize = statSync(dataPath('stars.bin')).size;
|
||||
assertCondition(binSize === stars.length * 3 * 4, `stars.bin size (${binSize}) does not match ${stars.length} stars.`);
|
||||
const positionBytes = statSync(dataPath('stars.bin')).size;
|
||||
assertCondition(positionBytes === stars.length * BYTES_PER_STAR_POSITION, `stars.bin size (${positionBytes}) does not match ${stars.length} stars.`);
|
||||
const metaBytes = statSync(dataPath('stars-meta.bin')).size;
|
||||
assertCondition(metaBytes === stars.length * BYTES_PER_STAR_META, `stars-meta.bin size (${metaBytes}) does not match ${stars.length} stars.`);
|
||||
|
||||
// Round-trips the written assets back through the decoder the app uses, so a format change
|
||||
// that only half-lands fails here rather than as a silently wrong star map.
|
||||
const { index, positions, meta } = encodeStarCatalog(stars);
|
||||
const decoded = decodeStarCatalog(index, positions, meta);
|
||||
assertCondition(decoded.length === stars.length, `Star catalogue round-trip lost records: ${decoded.length} of ${stars.length}.`);
|
||||
for (let i = 0; i < stars.length; i++) {
|
||||
assertCondition(decoded[i].id === stars[i].id && decoded[i].name === stars[i].name, `Star catalogue round-trip altered record ${i}.`);
|
||||
assertCondition(decoded[i].spectralType === stars[i].spectralType, `Star catalogue round-trip lost the spectral type of star ${stars[i].id}.`);
|
||||
assertCondition(decoded[i].colorIndex === null === (stars[i].colorIndex === null), `Star catalogue round-trip changed whether star ${stars[i].id} has a colour index.`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateBodies(bodies: BodyRecord[]): void {
|
||||
@@ -59,11 +77,68 @@ function validateExoplanets(exoplanets: ExoplanetRecord[], starIds: Set<number>)
|
||||
assertCondition(!!exoplanet.name, `Exoplanet ${exoplanet.id} has no name.`);
|
||||
if (exoplanet.hostStarId !== null) {
|
||||
assertCondition(starIds.has(exoplanet.hostStarId), `Exoplanet ${exoplanet.id} references unknown star id ${exoplanet.hostStarId}.`);
|
||||
// The Sun has no exoplanets, so any match to it is a matching failure — historically a
|
||||
// blank distance column parsing as 0, which puts the host at the origin and matches Sol
|
||||
// exactly. Free, permanent tripwire for that whole class of bug.
|
||||
assertCondition(
|
||||
exoplanet.hostStarId !== SUN_STAR_ID,
|
||||
`Exoplanet ${exoplanet.id} was matched to the Sun, which has no exoplanets — the host-star match is wrong.`
|
||||
);
|
||||
crossReferenced++;
|
||||
}
|
||||
|
||||
assertCondition(
|
||||
exoplanet.periodDays === undefined || exoplanet.periodDays > 0,
|
||||
`Exoplanet ${exoplanet.id} has a non-positive orbital period.`
|
||||
);
|
||||
assertCondition(
|
||||
exoplanet.hostStarMassSolar === undefined || exoplanet.hostStarMassSolar > 0,
|
||||
`Exoplanet ${exoplanet.id} has a non-positive host star mass.`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(` ${crossReferenced}/${exoplanets.length} exoplanets cross-referenced to a HYG host star.`);
|
||||
|
||||
// How many can be propagated at their real rate rather than as if the host were the Sun.
|
||||
const withPeriod = exoplanets.filter((exoplanet) => exoplanet.periodDays !== undefined).length;
|
||||
const withHostMass = exoplanets.filter((exoplanet) => exoplanet.hostStarMassSolar !== undefined).length;
|
||||
console.log(` ${withPeriod}/${exoplanets.length} have a measured period, ${withHostMass} a host star mass.`);
|
||||
}
|
||||
|
||||
const UNIT_VECTOR_TOLERANCE = 1e-6;
|
||||
|
||||
function validateDeepSky(objects: DeepSkyRecord[]): void {
|
||||
assertCondition(objects.length > 0, 'No deep-sky objects were produced.');
|
||||
|
||||
const ids = new Set<string>();
|
||||
for (const object of objects) {
|
||||
assertCondition(!!object.id, `Deep-sky object has no id: ${JSON.stringify(object)}`);
|
||||
assertCondition(!ids.has(object.id), `Duplicate deep-sky id: ${object.id}`);
|
||||
ids.add(object.id);
|
||||
assertCondition(!!object.name, `Deep-sky object ${object.id} has no name.`);
|
||||
|
||||
// Positions are directions, so every one of them must be a unit vector — a zero-length
|
||||
// or mis-scaled entry would silently collapse onto the origin on the backdrop shell.
|
||||
const length = Math.hypot(object.x, object.y, object.z);
|
||||
assertCondition(Math.abs(length - 1) < UNIT_VECTOR_TOLERANCE, `Deep-sky object ${object.id} has a non-unit direction (length ${length}).`);
|
||||
|
||||
assertCondition(object.angularSizeDeg >= 0, `Deep-sky object ${object.id} has a negative angular size.`);
|
||||
assertCondition(object.distancePc === null || object.distancePc > 0, `Deep-sky object ${object.id} has a non-positive distance.`);
|
||||
// The distance and its provenance have to travel together, or the UI cannot say where a
|
||||
// number came from.
|
||||
assertCondition(
|
||||
(object.distancePc === null) === (object.distanceMethod === null),
|
||||
`Deep-sky object ${object.id} has a distance/method mismatch.`
|
||||
);
|
||||
}
|
||||
|
||||
const kinds = new Set(objects.map((object) => object.kind));
|
||||
for (const kind of ['galaxy', 'nebula', 'cluster'] as const) {
|
||||
assertCondition(kinds.has(kind), `No deep-sky objects of kind "${kind}" were produced.`);
|
||||
}
|
||||
|
||||
const withDistance = objects.filter((object) => object.distancePc !== null).length;
|
||||
console.log(` ${withDistance}/${objects.length} deep-sky objects have a derived distance.`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,6 +148,9 @@ function validateExoplanets(exoplanets: ExoplanetRecord[], starIds: Set<number>)
|
||||
*/
|
||||
async function build(): Promise<void> {
|
||||
console.log('=== NASA star map ETL ===\n');
|
||||
console.log('Catalogues:');
|
||||
console.log(describeSources());
|
||||
console.log();
|
||||
|
||||
const stars = await fetchStars();
|
||||
console.log();
|
||||
@@ -80,16 +158,32 @@ async function build(): Promise<void> {
|
||||
console.log();
|
||||
const exoplanets = await fetchExoplanets(stars);
|
||||
console.log();
|
||||
const deepSky = await fetchDeepSky();
|
||||
console.log();
|
||||
|
||||
// The cross-reference depends on the star catalogue as much as on the archive, so it is
|
||||
// resolved again here against whatever catalogue this run produced. A no-op when the two were
|
||||
// fetched together, and the whole point when only one of them was.
|
||||
const rematch = rematchHostStars(exoplanets, stars);
|
||||
console.log(
|
||||
`Cross-referencing exoplanet hosts against ${stars.length} stars...\n` +
|
||||
` ${rematch.matched}/${rematch.total} matched` +
|
||||
(rematch.resolvable < rematch.total ? ` (${rematch.total - rematch.resolvable} records predate stored host coordinates and kept their existing match)` : '') +
|
||||
(rematch.gained || rematch.lost ? `; ${rematch.gained} gained, ${rematch.lost} lost` : '')
|
||||
);
|
||||
console.log();
|
||||
|
||||
console.log('Validating output...');
|
||||
validateStars(stars);
|
||||
validateBodies(bodies);
|
||||
validateExoplanets(exoplanets, new Set(stars.map((star) => star.id)));
|
||||
validateDeepSky(deepSky);
|
||||
|
||||
console.log('\nETL completed successfully:');
|
||||
console.log(` stars: ${stars.length}`);
|
||||
console.log(` bodies: ${bodies.length}`);
|
||||
console.log(` exoplanets: ${exoplanets.length}`);
|
||||
console.log(` deep sky: ${deepSky.length}`);
|
||||
}
|
||||
|
||||
build().catch((error) => {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
import { parseSexagesimal, raDecToUnitVector } from '../../src/app/shared/astro/coordinates';
|
||||
import { classifyOpenNgcType, estimateDeepSkyDistancePc, isNotableDeepSkyObject } from '../../src/app/shared/astro/deep-sky';
|
||||
import { DeepSkyRecord } from '../../src/app/shared/models/deepsky.model';
|
||||
import { parseCsvObjects } from './lib/csv';
|
||||
import { fetchTextCached } from './lib/http';
|
||||
import { dataPath, ensureDataDir } from './lib/paths';
|
||||
|
||||
const OPENNGC_CSV_URL = 'https://raw.githubusercontent.com/mattiaverga/OpenNGC/master/database_files/NGC.csv';
|
||||
/** OpenNGC publishes semicolon-separated files, not comma-separated. */
|
||||
const OPENNGC_DELIMITER = ';';
|
||||
|
||||
const ARCMIN_PER_DEGREE = 60;
|
||||
|
||||
/**
|
||||
* Reads a numeric catalog column. Empty strings mean "not measured" and must become `null`
|
||||
* rather than `0`: `Number('')` is `0`, which would silently turn every unphotometered object
|
||||
* into a magnitude-0 blaze brighter than Sirius.
|
||||
*/
|
||||
function numericField(value: string | undefined): number | null {
|
||||
if (value === undefined || value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function textField(value: string | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
/** OpenNGC stores Messier numbers zero-padded ("031"); render them as "M31". */
|
||||
function messierDesignation(value: string | undefined): string | null {
|
||||
const raw = textField(value);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const number = Number(raw);
|
||||
return Number.isFinite(number) ? `M${number}` : `M${raw}`;
|
||||
}
|
||||
|
||||
/** OpenNGC's `Common names` column is comma-separated; the first entry is the best known. */
|
||||
function primaryCommonName(value: string | undefined): string | null {
|
||||
const raw = textField(value);
|
||||
return raw ? (textField(raw.split(',')[0]) ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name, most recognisable first: a common name ("Andromeda Galaxy") beats a Messier
|
||||
* number ("M31"), which beats the raw catalog designation ("NGC0224").
|
||||
*/
|
||||
function resolveName(commonName: string | null, messier: string | null, designation: string): string {
|
||||
return commonName ?? messier ?? designation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the OpenNGC catalog, keeps the objects notable enough to be worth drawing, and
|
||||
* writes `deepsky.json` — each entry a unit direction on the celestial sphere plus its kind,
|
||||
* apparent size, magnitude and (where derivable) distance.
|
||||
*
|
||||
* See `DeepSkyRecord` for why these are stored as directions rather than positions.
|
||||
*/
|
||||
export async function fetchDeepSky(): Promise<DeepSkyRecord[]> {
|
||||
console.log('Fetching OpenNGC deep-sky catalog...');
|
||||
const csv = await fetchTextCached(OPENNGC_CSV_URL, 'openngc.csv');
|
||||
const rows = parseCsvObjects(csv, OPENNGC_DELIMITER);
|
||||
|
||||
const records: DeepSkyRecord[] = [];
|
||||
let skippedUnclassified = 0;
|
||||
let skippedNotNotable = 0;
|
||||
let skippedUnpositioned = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const kind = classifyOpenNgcType(row['Type']);
|
||||
if (!kind) {
|
||||
skippedUnclassified++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const magnitude = numericField(row['V-Mag']) ?? numericField(row['B-Mag']);
|
||||
const messier = messierDesignation(row['M']);
|
||||
const commonName = primaryCommonName(row['Common names']);
|
||||
|
||||
if (!isNotableDeepSkyObject({ messier, commonName, magnitude })) {
|
||||
skippedNotNotable++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const raHours = parseSexagesimal(row['RA']);
|
||||
const decDeg = parseSexagesimal(row['Dec']);
|
||||
if (raHours === null || decDeg === null) {
|
||||
skippedUnpositioned++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const designation = textField(row['Name']) ?? '';
|
||||
if (!designation) {
|
||||
skippedUnpositioned++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { x, y, z } = raDecToUnitVector(raHours, decDeg);
|
||||
const distance = estimateDeepSkyDistancePc({
|
||||
kind,
|
||||
redshift: numericField(row['Redshift']),
|
||||
parallaxMas: numericField(row['Pax'])
|
||||
});
|
||||
|
||||
records.push({
|
||||
id: designation,
|
||||
name: resolveName(commonName, messier, designation),
|
||||
kind,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
angularSizeDeg: (numericField(row['MajAx']) ?? 0) / ARCMIN_PER_DEGREE,
|
||||
magnitude,
|
||||
distancePc: distance?.distancePc ?? null,
|
||||
distanceMethod: distance?.method ?? null,
|
||||
constellation: textField(row['Const']) ?? 'Unknown',
|
||||
messier
|
||||
});
|
||||
}
|
||||
|
||||
// Brightest first, so a consumer taking a prefix gets the most prominent objects. Objects
|
||||
// with no measured magnitude sort last rather than being treated as infinitely bright.
|
||||
records.sort((a, b) => (a.magnitude ?? Infinity) - (b.magnitude ?? Infinity) || a.id.localeCompare(b.id));
|
||||
|
||||
ensureDataDir();
|
||||
writeFileSync(dataPath('deepsky.json'), JSON.stringify(records));
|
||||
|
||||
const withDistance = records.filter((record) => record.distancePc !== null).length;
|
||||
console.log(` kept ${records.length} deep-sky objects (of ${rows.length} catalog rows).`);
|
||||
console.log(` skipped: ${skippedUnclassified} not deep-sky, ${skippedNotNotable} too faint, ${skippedUnpositioned} unusable coordinates.`);
|
||||
console.log(` ${withDistance}/${records.length} have a derivable distance.`);
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
fetchDeepSky().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { buildStarNameIndex, resolveHostStarId } from '../../src/app/shared/astr
|
||||
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
|
||||
import { StarRecord } from '../../src/app/shared/models/star.model';
|
||||
import { fetchStars } from './fetchStars';
|
||||
import { parseCsvObjects } from './lib/csv';
|
||||
import { parseCsvObjects, parseOptionalNumber } from './lib/csv';
|
||||
import { fetchTextCached } from './lib/http';
|
||||
import { dataPath, ensureDataDir } from './lib/paths';
|
||||
|
||||
@@ -22,6 +22,7 @@ const TAP_COLUMNS = [
|
||||
'pl_orbper',
|
||||
'pl_rade',
|
||||
'pl_bmasse',
|
||||
'st_mass',
|
||||
'disc_year'
|
||||
].join(',');
|
||||
const TAP_QUERY = `select+${TAP_COLUMNS}+from+ps+where+default_flag=1&format=csv`;
|
||||
@@ -45,9 +46,11 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
|
||||
|
||||
let matched = 0;
|
||||
const exoplanets: ExoplanetRecord[] = rows.map((row, index) => {
|
||||
const raDeg = Number(row['ra']);
|
||||
const decDeg = Number(row['dec']);
|
||||
const distancePc = Number(row['sy_dist']);
|
||||
// `parseOptionalNumber`, not `Number`: a blank cell would otherwise become 0, which is a
|
||||
// finite, plausible-looking coordinate rather than the "not measured" it actually means.
|
||||
const raDeg = parseOptionalNumber(row['ra']) ?? Number.NaN;
|
||||
const decDeg = parseOptionalNumber(row['dec']) ?? Number.NaN;
|
||||
const distancePc = parseOptionalNumber(row['sy_dist']) ?? Number.NaN;
|
||||
|
||||
const hostStarId = resolveHostStarId(
|
||||
{ hostname: row['hostname'], raDeg, decDeg, distancePc },
|
||||
@@ -67,6 +70,16 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
|
||||
radiusEarth: parseOptionalNumber(row['pl_rade']),
|
||||
massEarth: parseOptionalNumber(row['pl_bmasse']),
|
||||
discoveryYear: parseOptionalNumber(row['disc_year']),
|
||||
// The period was already being downloaded and thrown away. With the semi-major axis it
|
||||
// determines the host's gravitational parameter, so keeping it is the difference between
|
||||
// propagating a planet at its real rate and pretending every host is the Sun.
|
||||
periodDays: parseOptionalNumber(row['pl_orbper']),
|
||||
hostStarMassSolar: parseOptionalNumber(row['st_mass']),
|
||||
// Kept so the cross-reference can be redone without the archive; see the record's own
|
||||
// documentation. Undefined rather than NaN, which JSON cannot represent.
|
||||
hostRaDeg: parseOptionalNumber(row['ra']),
|
||||
hostDecDeg: parseOptionalNumber(row['dec']),
|
||||
hostDistancePc: parseOptionalNumber(row['sy_dist']),
|
||||
orbit: {
|
||||
semiMajorAxisAu: parseOptionalNumber(row['pl_orbsmax']),
|
||||
eccentricity: parseOptionalNumber(row['pl_orbeccen']),
|
||||
@@ -82,14 +95,6 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
|
||||
return exoplanets;
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value: string | undefined): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
fetchExoplanets().catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
+79
-17
@@ -1,16 +1,37 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
import { raDecDistanceToXyz } from '../../src/app/shared/astro/coordinates';
|
||||
import { mergeStarCatalogues } from '../../src/app/shared/astro/star-merge';
|
||||
import { encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
|
||||
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
||||
import { parseCsvObjects } from './lib/csv';
|
||||
import { positionalSources } from './sources/registry';
|
||||
import { PARALLAX_PRECISION_MAS } from './sources/star-sources';
|
||||
import { parseCsvObjects, parseOptionalNumber } from './lib/csv';
|
||||
import { fetchTextCached } from './lib/http';
|
||||
import { dataPath, ensureDataDir } from './lib/paths';
|
||||
|
||||
const HYG_CSV_URL = 'https://raw.githubusercontent.com/astronexus/HYG-Database/main/hyg/CURRENT/hygdata_v41.csv';
|
||||
const HYG_UNKNOWN_DISTANCE_PC = 100000; // HYG's placeholder for unmeasured/unreliable parallax
|
||||
|
||||
/** Stars within this distance (parsecs) of the Sun are kept for the galaxy view. */
|
||||
const DISTANCE_CUTOFF_PC = Number(process.env['ETL_STAR_DISTANCE_PC'] ?? 50);
|
||||
/**
|
||||
* Stand-in magnitude for a star with no photometry. Faint rather than 0, because 0 would mean
|
||||
* "as bright as Vega" and render it as one of the largest points on the map.
|
||||
*/
|
||||
const UNKNOWN_MAGNITUDE = 15;
|
||||
|
||||
/**
|
||||
* Stars within this distance (parsecs) of the Sun are kept for the galaxy view.
|
||||
*
|
||||
* Set at the range HYG's own measurements reach rather than at a round number. 98.6% of its
|
||||
* rows carry a Hipparcos identifier, and Hipparcos parallaxes are good to roughly a
|
||||
* milliarcsecond — so at 250 pc (4 mas) a star's distance is uncertain by some tens of per
|
||||
* cent, and beyond it the catalogue is plotting noise. Note that only the *radial* placement
|
||||
* blurs: a star's direction on the sky stays exact at any distance.
|
||||
*
|
||||
* The catalogue is also magnitude-limited, so this is not a volume-complete sample beyond about
|
||||
* 50 pc: it thins to the intrinsically bright, which is the same selection the naked eye makes.
|
||||
*/
|
||||
const DISTANCE_CUTOFF_PC = Number(process.env['ETL_STAR_DISTANCE_PC'] ?? 250);
|
||||
|
||||
function resolveName(row: Record<string, string>): string {
|
||||
if (row['proper']) {
|
||||
@@ -26,7 +47,10 @@ function resolveName(row: Record<string, string>): string {
|
||||
return `HD ${row['hd']}`;
|
||||
}
|
||||
if (row['gl']) {
|
||||
return `Gl ${row['gl']}`;
|
||||
// Already a complete designation ("Gl 581", "GJ 3512"), unlike the bare numbers in `hd`
|
||||
// and `hip` — prefixing it again produced 2331 stars named "Gl GJ 1076", which broke
|
||||
// search, the on-screen labels, and exoplanet host-star name matching alike.
|
||||
return row['gl'];
|
||||
}
|
||||
if (row['hip']) {
|
||||
return `HIP ${row['hip']}`;
|
||||
@@ -51,7 +75,7 @@ export async function fetchStars(): Promise<StarRecord[]> {
|
||||
const distancePc = Number(row['dist']);
|
||||
|
||||
if (id === SUN_STAR_ID) {
|
||||
stars.push({ id, name: 'Sol', x: 0, y: 0, z: 0, magnitude: Number(row['mag']), spectralType: row['spect'] || 'G2V', colorIndex: Number(row['ci']) || 0 });
|
||||
stars.push({ id, name: 'Sol', x: 0, y: 0, z: 0, magnitude: parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE, spectralType: row['spect'] || 'G2V', colorIndex: parseOptionalNumber(row['ci']) ?? null });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -73,31 +97,69 @@ export async function fetchStars(): Promise<StarRecord[]> {
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
magnitude: Number(row['mag']) || 0,
|
||||
magnitude: parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE,
|
||||
spectralType: row['spect'] || 'Unknown',
|
||||
colorIndex: Number(row['ci']) || 0
|
||||
colorIndex: parseOptionalNumber(row['ci']) ?? null
|
||||
});
|
||||
}
|
||||
|
||||
stars.sort((a, b) => a.id - b.id);
|
||||
writeStarAssets(stars);
|
||||
|
||||
console.log(` kept ${stars.length} stars (of ${rows.length} in the catalog).`);
|
||||
|
||||
const merged = await mergeWithOtherSources(stars);
|
||||
merged.sort((a, b) => a.id - b.id);
|
||||
writeStarAssets(merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unions HYG with every other positional source that is wired in and reachable.
|
||||
*
|
||||
* A source that cannot be reached is reported and skipped rather than failing the run. That is
|
||||
* not defensive padding: the archives this would draw on are frequently unavailable, and a build
|
||||
* that produces a smaller catalogue is far better than one that produces none.
|
||||
*/
|
||||
async function mergeWithOtherSources(hygStars: StarRecord[]): Promise<StarRecord[]> {
|
||||
const others = positionalSources().filter((source) => source.id !== 'hyg');
|
||||
if (others.length === 0) {
|
||||
return hygStars;
|
||||
}
|
||||
|
||||
const candidates = [{ sourceId: 'hyg', parallaxPrecisionMas: PARALLAX_PRECISION_MAS['hyg'], stars: hygStars }];
|
||||
|
||||
for (const source of others) {
|
||||
try {
|
||||
candidates.push({
|
||||
sourceId: source.id,
|
||||
parallaxPrecisionMas: PARALLAX_PRECISION_MAS[source.id] ?? 1,
|
||||
stars: await source.fetch!()
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(` skipping ${source.name}: ${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 1) {
|
||||
return hygStars;
|
||||
}
|
||||
|
||||
const { stars, summary } = mergeStarCatalogues(candidates);
|
||||
console.log(` merged ${summary.total} stars from ${candidates.length} catalogues (${summary.duplicates} duplicates resolved to the better parallax):`);
|
||||
for (const [sourceId, count] of Object.entries(summary.bySource)) {
|
||||
console.log(` ${sourceId}: ${count}`);
|
||||
}
|
||||
return stars;
|
||||
}
|
||||
|
||||
function writeStarAssets(stars: StarRecord[]): void {
|
||||
ensureDataDir();
|
||||
|
||||
const positions = new Float32Array(stars.length * 3);
|
||||
stars.forEach((star, index) => {
|
||||
positions[index * 3] = star.x;
|
||||
positions[index * 3 + 1] = star.y;
|
||||
positions[index * 3 + 2] = star.z;
|
||||
});
|
||||
// The layout lives in `star-catalog.ts`, which the app decodes with — one definition, so the
|
||||
// writer and the reader cannot drift.
|
||||
const { index, positions, meta } = encodeStarCatalog(stars);
|
||||
|
||||
writeFileSync(dataPath('stars.bin'), Buffer.from(positions.buffer));
|
||||
writeFileSync(dataPath('stars-index.json'), JSON.stringify(stars));
|
||||
writeFileSync(dataPath('stars-meta.bin'), Buffer.from(meta));
|
||||
writeFileSync(dataPath('stars-index.json'), JSON.stringify(index));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
|
||||
@@ -51,6 +51,23 @@ export function parseCsv(text: string, delimiter = ','): string[][] {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a numeric CSV cell, treating a missing/blank/unparseable value as absent.
|
||||
*
|
||||
* Always prefer this to a bare `Number(cell)`: catalogs leave unmeasured fields empty, and
|
||||
* `Number('')` is `0` — a finite, entirely plausible-looking value. That single coercion has
|
||||
* already produced two separate bugs here, painting 875 unphotometered stars as if they had a
|
||||
* measured colour index of 0, and placing exoplanet hosts at the origin where they matched the
|
||||
* Sun.
|
||||
*/
|
||||
export function parseOptionalNumber(value: string | undefined): number | undefined {
|
||||
if (value === undefined || value.trim() === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
/** Parses `text` as CSV and maps each data row to an object keyed by the header row. */
|
||||
export function parseCsvObjects(text: string, delimiter = ','): Array<Record<string, string>> {
|
||||
const rows = parseCsv(text, delimiter).filter((row) => row.some((cell) => cell.length > 0));
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
import { RematchSummary, rematchHostStars } from '../../src/app/shared/astro/host-star-matching';
|
||||
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
|
||||
import { StarRecord } from '../../src/app/shared/models/star.model';
|
||||
import { dataPath } from './lib/paths';
|
||||
|
||||
/**
|
||||
* Reads the written assets, re-resolves every exoplanet's host star against the given catalogue,
|
||||
* and writes the exoplanets back. The matching itself lives with the matcher, in
|
||||
* `host-star-matching.ts`; this is only the file handling around it.
|
||||
*/
|
||||
export function rematchWrittenAssets(stars: readonly StarRecord[]): RematchSummary {
|
||||
const exoplanets = JSON.parse(readFileSync(dataPath('exoplanets.json'), 'utf8')) as ExoplanetRecord[];
|
||||
const summary = rematchHostStars(exoplanets, stars);
|
||||
writeFileSync(dataPath('exoplanets.json'), JSON.stringify(exoplanets));
|
||||
return summary;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { raDegDecDistanceToXyz } from '../../../src/app/shared/astro/coordinates';
|
||||
import { StarRecord } from '../../../src/app/shared/models/star.model';
|
||||
import { parseCsvObjects, parseOptionalNumber } from '../lib/csv';
|
||||
import { fetchTextCached } from '../lib/http';
|
||||
|
||||
/**
|
||||
* Gaia DR3, via the ESA archive's TAP service.
|
||||
*
|
||||
* The only one of the large modern surveys that can add stars to a *3D* map, because it is the
|
||||
* only one that measures parallaxes for them. Its 1.8 billion sources are roughly 1% of the
|
||||
* Galaxy — no catalogue is close to the rest — but within a few hundred parsecs it is complete
|
||||
* in a way Hipparcos never was, and its parallaxes are fifty times more precise.
|
||||
*
|
||||
* **This has never been run.** Every ESA, NOIRLab, SDSS and Euclid endpoint is unreachable from
|
||||
* the environment this was written in, so the query below is written against the published DR3
|
||||
* schema and has not been executed against it. Treat the column names as the first thing to
|
||||
* check if a real run misbehaves.
|
||||
*/
|
||||
|
||||
const GAIA_TAP_URL = 'https://gea.esac.esa.int/tap-server/tap/sync';
|
||||
|
||||
/**
|
||||
* How far out to take Gaia, in parsecs, and the faintest star to keep.
|
||||
*
|
||||
* Both exist to bound the download rather than the science. Gaia's parallaxes stay useful far
|
||||
* past anything this map draws, so the limit here is a payload decision: the catalogue is baked
|
||||
* into a static asset that a browser downloads before the first frame.
|
||||
*/
|
||||
const DISTANCE_CUTOFF_PC = Number(process.env['ETL_GAIA_DISTANCE_PC'] ?? 250);
|
||||
const MAGNITUDE_LIMIT = Number(process.env['ETL_GAIA_MAGNITUDE_LIMIT'] ?? 12);
|
||||
const ROW_LIMIT = Number(process.env['ETL_GAIA_ROW_LIMIT'] ?? 500000);
|
||||
|
||||
/**
|
||||
* Relative parallax error above which a star is dropped: a parallax measured to worse than 20%
|
||||
* gives a distance that is not worth plotting, and inverting a noisy parallax biases it badly.
|
||||
*/
|
||||
const MAX_PARALLAX_ERROR_RATIO = 0.2;
|
||||
|
||||
/** Parallax in milliarcseconds for a given distance — the query's cutoff, expressed as Gaia has it. */
|
||||
function parallaxFloorMas(distancePc: number): number {
|
||||
return 1000 / distancePc;
|
||||
}
|
||||
|
||||
function buildQuery(): string {
|
||||
return [
|
||||
`select top ${ROW_LIMIT}`,
|
||||
'source_id, ra, dec, parallax, parallax_error, phot_g_mean_mag, bp_rp',
|
||||
'from gaiadr3.gaia_source',
|
||||
`where parallax > ${parallaxFloorMas(DISTANCE_CUTOFF_PC).toFixed(6)}`,
|
||||
`and parallax_over_error > ${(1 / MAX_PARALLAX_ERROR_RATIO).toFixed(1)}`,
|
||||
`and phot_g_mean_mag < ${MAGNITUDE_LIMIT}`,
|
||||
'order by phot_g_mean_mag asc'
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gaia publishes no spectral classifications, but `bp_rp` is a colour index on the same footing
|
||||
* as HYG's `ci` — so the app's existing colour and spectral-class handling works unchanged, and
|
||||
* the spectral type is left as unknown rather than invented from the colour.
|
||||
*/
|
||||
const UNKNOWN_SPECTRAL_TYPE = 'Unknown';
|
||||
|
||||
/**
|
||||
* Gaia source ids are 19 digits and there are no proper names, so a star's identity here is its
|
||||
* catalogue designation. The app's star ids are 32-bit, which a Gaia source id overflows, so the
|
||||
* two are kept apart: `id` is assigned within this run's own range and the designation carries
|
||||
* the real identifier in the name.
|
||||
*/
|
||||
const GAIA_ID_BASE = 1_000_000_000;
|
||||
|
||||
export async function fetchGaiaStars(): Promise<StarRecord[]> {
|
||||
const url = `${GAIA_TAP_URL}?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&QUERY=${encodeURIComponent(buildQuery())}`;
|
||||
console.log(`Fetching Gaia DR3 (within ${DISTANCE_CUTOFF_PC} pc, G < ${MAGNITUDE_LIMIT}, at most ${ROW_LIMIT} rows)...`);
|
||||
|
||||
const csv = await fetchTextCached(url, 'gaia-dr3.csv');
|
||||
const rows = parseCsvObjects(csv);
|
||||
const stars: StarRecord[] = [];
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const parallaxMas = parseOptionalNumber(row['parallax']);
|
||||
const raDeg = parseOptionalNumber(row['ra']);
|
||||
const decDeg = parseOptionalNumber(row['dec']);
|
||||
if (!parallaxMas || parallaxMas <= 0 || raDeg === undefined || decDeg === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distancePc = 1000 / parallaxMas;
|
||||
if (distancePc > DISTANCE_CUTOFF_PC) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { x, y, z } = raDegDecDistanceToXyz(raDeg, decDeg, distancePc);
|
||||
stars.push({
|
||||
id: GAIA_ID_BASE + index,
|
||||
name: `Gaia DR3 ${row['source_id']}`,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
magnitude: parseOptionalNumber(row['phot_g_mean_mag']) ?? MAGNITUDE_LIMIT,
|
||||
spectralType: UNKNOWN_SPECTRAL_TYPE,
|
||||
colorIndex: parseOptionalNumber(row['bp_rp']) ?? null,
|
||||
source: 'gaia'
|
||||
});
|
||||
});
|
||||
|
||||
console.log(` kept ${stars.length} Gaia stars (of ${rows.length} rows).`);
|
||||
return stars;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { fetchGaiaStars } from './gaia';
|
||||
import { StarSource } from './star-sources';
|
||||
|
||||
/**
|
||||
* Every catalogue this pipeline knows about, wired in or not, with an honest note on each.
|
||||
*
|
||||
* Kept as data so `npm run etl` can print what it actually used rather than what a README claims
|
||||
* it uses. The four unimplemented entries are not placeholders for missing work: three of them
|
||||
* cannot contribute stars to a 3D map at all, for the reason recorded against each.
|
||||
*/
|
||||
export const STAR_SOURCES: readonly StarSource[] = [
|
||||
{
|
||||
id: 'hyg',
|
||||
name: 'HYG database (Hipparcos, Yale Bright Star, Gliese)',
|
||||
role: 'positional',
|
||||
endpoint: 'https://raw.githubusercontent.com/astronexus/HYG-Database',
|
||||
contributes: 'A complete, named, spectrally classified bright-star catalogue with parallaxes — 68388 stars within 250 pc.',
|
||||
unimplementedBecause: null
|
||||
},
|
||||
{
|
||||
id: 'gaia',
|
||||
name: 'Gaia DR3',
|
||||
role: 'positional',
|
||||
endpoint: 'https://gea.esac.esa.int/tap-server/tap/sync',
|
||||
contributes:
|
||||
'Parallaxes fifty times more precise than Hipparcos, for 1.8 billion sources — the only survey that can add stars to a 3D map, because it is the only one that measures how far away they are.',
|
||||
unimplementedBecause: null,
|
||||
fetch: fetchGaiaStars
|
||||
},
|
||||
{
|
||||
id: 'decaps2',
|
||||
name: 'DECaPS2 (Dark Energy Camera Plane Survey)',
|
||||
role: 'backdrop',
|
||||
endpoint: 'https://datalab.noirlab.edu/tap',
|
||||
contributes:
|
||||
'The deepest optical census of the southern galactic plane: 3.32 billion objects across 130 degrees, in the dust-obscured region every other catalogue thins out in.',
|
||||
unimplementedBecause:
|
||||
'Photometric only — no parallaxes, so not one of its 3.32 billion objects can be placed in depth. Fifty times Gaia’s object count and zero stars this map can position. It would enter as a direction-only backdrop layer, alongside the deep-sky shell.'
|
||||
},
|
||||
{
|
||||
id: 'sdss5-mwm',
|
||||
name: 'SDSS-V Milky Way Mapper',
|
||||
role: 'enrichment',
|
||||
endpoint: 'https://api.sdss.org',
|
||||
contributes:
|
||||
'All-sky optical and infrared spectroscopy: effective temperatures, surface gravities, metallicities and radial velocities, which is real spectral classification rather than a colour index standing in for one.',
|
||||
unimplementedBecause:
|
||||
'Adds no positions. It is keyed to targets Gaia already places, so it joins onto an existing catalogue rather than extending it — worth having once Gaia is in, and worth nothing before.'
|
||||
},
|
||||
{
|
||||
id: 'euclid-q2-bulge',
|
||||
name: 'Euclid Galactic Bulge Survey (Q2)',
|
||||
role: 'backdrop',
|
||||
endpoint: 'https://easidr.esac.esa.int/sas',
|
||||
contributes: 'High-resolution imagery and astrometry of the crowded inner bulge, around 8 kpc away.',
|
||||
unimplementedBecause:
|
||||
'At 8 kpc a parallax is a few microarcseconds, so this is astrometry without usable distances for a map of this kind. Its natural use here is imagery — a real photograph of the bulge on the galactic view, in place of part of the procedural model.'
|
||||
},
|
||||
{
|
||||
id: 'saga',
|
||||
name: 'SAGA (Stellar Abundances for Galactic Archaeology)',
|
||||
role: 'enrichment',
|
||||
endpoint: 'https://sagadatabase.jp',
|
||||
contributes: 'Compiled elemental abundances for metal-poor stars — the chemical record of how the Galaxy assembled.',
|
||||
unimplementedBecause:
|
||||
'A compilation keyed to stars other catalogues place, covering tens of thousands of objects rather than millions. Like SDSS-V it enriches; it cannot extend the map on its own.'
|
||||
}
|
||||
];
|
||||
|
||||
export function positionalSources(): readonly StarSource[] {
|
||||
return STAR_SOURCES.filter((source) => source.role === 'positional' && source.fetch !== undefined);
|
||||
}
|
||||
|
||||
/** A one-line-per-source report of what the pipeline can and cannot draw on. */
|
||||
export function describeSources(): string {
|
||||
return STAR_SOURCES.map((source) => {
|
||||
const status = source.unimplementedBecause === null ? (source.fetch ? 'wired in' : 'wired in (built separately)') : 'declared, not fetched';
|
||||
return ` [${source.role}] ${source.name} — ${status}`;
|
||||
}).join('\n');
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { StarRecord } from '../../../src/app/shared/models/star.model';
|
||||
|
||||
/**
|
||||
* The catalogues this map can draw on, and what each of them can actually contribute.
|
||||
*
|
||||
* The distinction that matters is not size. It is whether a catalogue knows how *far away* its
|
||||
* objects are, because a 3D map cannot place a star it only has a direction for. That splits the
|
||||
* available surveys into three roles which are not interchangeable, and a survey being enormous
|
||||
* says nothing about which role it fills — DECaPS2 has fifty times Gaia's object count and
|
||||
* cannot place a single one of them in depth.
|
||||
*/
|
||||
export type StarSourceRole =
|
||||
/** Has a direction *and* a distance, usually from parallax. Can put a star in the scene. */
|
||||
| 'positional'
|
||||
/** Has stellar parameters keyed to an identifier. Adds knowledge about stars already placed. */
|
||||
| 'enrichment'
|
||||
/** Has directions but no usable distance. Can only be painted on the backdrop shell. */
|
||||
| 'backdrop';
|
||||
|
||||
export interface StarSource {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly role: StarSourceRole;
|
||||
/** Where the data comes from, for the credits and for anyone re-running the pipeline. */
|
||||
readonly endpoint: string;
|
||||
/** What this source adds that the others do not. */
|
||||
readonly contributes: string;
|
||||
/**
|
||||
* Why it is not yet wired in, or `null` when it is. Kept as data rather than as a comment so
|
||||
* the ETL can print an honest summary of what actually ran.
|
||||
*/
|
||||
readonly unimplementedBecause: string | null;
|
||||
/** Fetches this source's stars. Absent for sources that are declared but not implemented. */
|
||||
readonly fetch?: () => Promise<StarRecord[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Precision of the parallax each positional source measures with, in milliarcseconds — which is
|
||||
* what decides how far out its distances stay meaningful, and which of two catalogues to believe
|
||||
* when both contain the same star.
|
||||
*/
|
||||
export const PARALLAX_PRECISION_MAS: Readonly<Record<string, number>> = {
|
||||
hyg: 1,
|
||||
gaia: 0.02
|
||||
};
|
||||
Reference in New Issue
Block a user