Compare commits
@@ -41,12 +41,17 @@ jobs:
|
|||||||
|
|
||||||
# Gaia DR3 is a frozen release: the same query returns the same bytes (a live re-fetch has
|
# Gaia DR3 is a frozen release: the same query returns the same bytes (a live re-fetch has
|
||||||
# reproduced stars.bin exactly), so its responses are carried from one run to the next
|
# reproduced stars.bin exactly), so its responses are carried from one run to the next
|
||||||
# rather than re-downloaded every week from an archive that times out under load. The key
|
# rather than re-downloaded every week from an archive that times out under load. So is
|
||||||
# follows gaia.ts, where the queries are written, so a changed query is fetched afresh.
|
# van Leeuwen's 2007 Hipparcos reduction, as frozen and on the same archive, whose parallax
|
||||||
|
# errors fetchStars requires: left out, it was fetched live every week, and an ESA outage would
|
||||||
|
# have failed the refresh with every Gaia answer cached. The key follows gaia.ts, where the
|
||||||
|
# queries are written, so a changed query is fetched afresh.
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: tools/etl/.cache/gaia-dr3-*.csv
|
path: |
|
||||||
key: gaia-dr3-${{ hashFiles('tools/etl/sources/gaia.ts') }}
|
tools/etl/.cache/gaia-dr3-*.csv
|
||||||
|
tools/etl/.cache/hipparcos-errors-*.csv
|
||||||
|
key: gaia-dr3-hipparcos-${{ hashFiles('tools/etl/sources/gaia.ts') }}
|
||||||
|
|
||||||
# Every other source is fetched live on this fresh runner. A failed fetch fails the run by
|
# Every other source is fetched live on this fresh runner. A failed fetch fails the run by
|
||||||
# design — no refresh is better than a partial one. That includes Gaia on a cold cache, by
|
# design — no refresh is better than a partial one. That includes Gaia on a cold cache, by
|
||||||
|
|||||||
@@ -53,24 +53,38 @@ in it is measured and what is not.
|
|||||||

|

|
||||||
|
|
||||||
**System view** — selecting a star flies the camera continuously into its system rather than
|
**System view** — selecting a star flies the camera continuously into its system rather than
|
||||||
cutting to a new scene. The Sun gets the real solar-system bodies from JPL Horizons; other
|
cutting to a new scene. The Sun gets the real solar-system bodies, moving on JPL's mean orbital
|
||||||
|
elements — Standish's for the planets, JPL SSD's satellite table for the moons, the Small-Body
|
||||||
|
Database for Ceres, Eris, Haumea and Makemake — and turned by the IAU's rotational elements
|
||||||
|
(Eris, Haumea, Makemake and Nereid, which have none, at their measured days about their orbit
|
||||||
|
normals, and Hyperion, which tumbles, not at all), Earth by the IERS Earth Rotation Angle; a
|
||||||
|
tidally locked moon's prime meridian turns at its JPL mean motion, and its pole's terms that turn
|
||||||
|
within 5 per cent of a multiple of its node's rate at that multiple of its JPL node rate, both
|
||||||
|
re-phased to the IAU's values on 2025-01-01 (the Moon's and Phobos's are left as the IAU has them,
|
||||||
|
and so are the circles Ariel's, Umbriel's, Titania's and Oberon's poles go round on, at rates none
|
||||||
|
of their nodes has), and Iapetus's pole follows its orbit normal
|
||||||
|
(`lockedToOrbit`), so each keeps its face to its planet from AD 1 to 3000; other
|
||||||
stars get their confirmed exoplanets. Orbits are drawn as ellipses and bodies are propagated
|
stars get their confirmed exoplanets. Orbits are drawn as ellipses and bodies are propagated
|
||||||
along them by a Kepler solver against the current epoch. Under them, a dashed grid marks out
|
along them by a Kepler solver to the date on the map's clock. Under them, a dashed grid marks out
|
||||||
round distances in AU — 5 AU rings for the solar system, 0.01 AU rings for TRAPPIST-1 — with a
|
round distances in AU — 5 AU rings for the solar system, 0.01 AU rings for TRAPPIST-1 — with a
|
||||||
drop line from each body, so eccentricity and inclination read against a circular reference
|
drop line from each body, so eccentricity and inclination read against a circular reference
|
||||||
instead of having to be inferred from a shape in space. The camera frames that grid rather than
|
instead of having to be inferred from a shape in space. The camera frames that grid rather than
|
||||||
the orbits, from the field of view it actually has, so the outermost ring sits inside the frame
|
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.
|
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
|
The star at the centre is drawn at its own radius, to the same scale as its orbits: the
|
||||||
its closest planet, while the camera is placed to frame the *outermost* ring — and in the solar
|
archive's measured radius for a planet host, and otherwise one derived from its luminosity and
|
||||||
system those differ by a factor of a hundred. At the distance that fits Pluto in view, a disc
|
temperature (Stefan-Boltzmann), which the card marks with what the temperature came from: "from
|
||||||
that stays clear of Mercury is about a pixel across, and no radius satisfies both. So the disc
|
colour and brightness", "from its type and brightness" for a giant or a star whose colour the
|
||||||
stays honest to the orbits and the star's halo carries its visibility, floored against the framed
|
dwarf table does not read, or "from its temperature and brightness" for an archive host whose
|
||||||
radius: light is not a surface, and a glow reaching past the innermost orbit says the star is
|
colour was read off its temperature. Its surface is a limb-darkened disc in the colour of a
|
||||||
bright rather than that it is large. That floor is bounded from both sides — large enough that
|
blackbody at its temperature, and its planets are lit
|
||||||
the star reads at a glance, small enough that Venus's and Earth's orbits stay legible as rings
|
in that colour, relative to the Sun's, so the solar system's photographs stay as they were
|
||||||
around it. Mercury's, three pixels wide at that range, does not survive either way.
|
taken. A star nothing gives a size or a temperature for is a grey point. Like every marker, the
|
||||||
|
disc is never drawn smaller than three pixels, so a red dwarf framed with its outermost orbit
|
||||||
|
still shows; there is no halo. The camera comes no closer to its centre than 0.05 AU or three of
|
||||||
|
its radii, whichever is further, and a giant is framed far enough back that its disc stays
|
||||||
|
inside the ring its neighbours' names are drawn on.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -109,7 +123,8 @@ its own readout, so a stale image is visible as one.
|
|||||||
both backends. Their size is angular rather than world-space — real stars are unresolvable
|
both backends. Their size is angular rather than world-space — real stars are unresolvable
|
||||||
point sources, so apparent size should follow brightness, not distance.
|
point sources, so apparent size should follow brightness, not distance.
|
||||||
- **One reference frame, from three sources.** HYG gives star positions in equatorial J2000.
|
- **One reference frame, from three sources.** HYG gives star positions in equatorial J2000.
|
||||||
JPL Horizons reports orbital elements against the ecliptic, tilted 23.4° away. The Exoplanet
|
JPL gives the planets' orbital elements against the ecliptic, tilted 23.4° away, and the moons'
|
||||||
|
against the ecliptic (the Moon), a Laplace plane, or their planet's equator (Uranus's and Pluto's). The Exoplanet
|
||||||
Archive measures inclination from the *plane of the sky* — perpendicular to our line of sight
|
Archive measures inclination from the *plane of the sky* — perpendicular to our line of sight
|
||||||
to each host star, which is why transiting planets cluster at 90°. Each set of elements is
|
to each host star, which is why transiting planets cluster at 90°. Each set of elements is
|
||||||
rotated from its own reference plane into the scene's equatorial frame, so a direction means
|
rotated from its own reference plane into the scene's equatorial frame, so a direction means
|
||||||
@@ -134,9 +149,11 @@ its own readout, so a stale image is visible as one.
|
|||||||
|
|
||||||
### On surfaces that were never photographed
|
### On surfaces that were never photographed
|
||||||
|
|
||||||
Fifteen bodies here have a real photograph. Everything else does not, and never will on current
|
Twenty-eight bodies here are wrapped in real photography: the Sun, the eight planets and the Moon,
|
||||||
instruments: no exoplanet's surface has ever been imaged, and a few of the solar system's own
|
and eighteen moons and dwarf planets in mission mosaics, grey where no probe has seen them
|
||||||
moons have no usable map in this asset set either.
|
(`src/assets/textures/README.md`). Everything else is not, and no exoplanet ever will be on
|
||||||
|
current instruments: none has had its surface imaged. The five large moons of Uranus and a few
|
||||||
|
small bodies have no map in this asset set either.
|
||||||
|
|
||||||
Those bodies get a surface reasoned from what *has* been measured, in a chain that is worth
|
Those bodies get a surface reasoned from what *has* been measured, in a chain that is worth
|
||||||
following because every link is standard:
|
following because every link is standard:
|
||||||
@@ -199,16 +216,18 @@ re-runs are cheap and offline-friendly; set `ETL_FORCE_REFRESH=1` to bypass the
|
|||||||
|
|
||||||
| Script | Source | Output |
|
| Script | Source | Output |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `fetchStars.ts` | HYG database, plus any other positional catalogue wired in (see below) | `stars.bin`, `stars-meta.bin`, `stars-index.json` |
|
| `fetchStars.ts` | HYG database, plus any other positional catalogue wired in (see below) | the catalogue stars, handed to `fetchExoplanets.ts` |
|
||||||
| `fetchSolarSystem.ts` | JPL Horizons / SSD | `bodies.json` |
|
| `fetchSolarSystem.ts` | JPL SSD mean elements (Standish's planets, the satellite table), the Small-Body Database, NAIF's PCK, JPL Horizons | `bodies.json` |
|
||||||
| `fetchExoplanets.ts` | NASA Exoplanet Archive (TAP) | `exoplanets.json` |
|
| `fetchExoplanets.ts` | NASA Exoplanet Archive (TAP), and the stars above with the hosts it adds | `exoplanets.json`, `stars.bin`, `stars-meta.bin`, `stars-index.json` |
|
||||||
| `fetchDeepSky.ts` | OpenNGC | `deepsky.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
|
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
|
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.
|
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
|
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`
|
`stars-meta.bin` (id, magnitude, colour index, spectral-type index, the distance's relative
|
||||||
|
error in two bytes, and the band and colour system those were measured in or whether the colour
|
||||||
|
was read off a temperature), and leaves `stars-index.json`
|
||||||
holding only the strings, with the ~2 600 distinct spectral classifications collapsed into a
|
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
|
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.
|
and both the ETL and the app use it, so the writer and the reader cannot drift apart.
|
||||||
@@ -281,8 +300,8 @@ So deep-sky records store a **unit direction** on the celestial sphere rather th
|
|||||||
the line of sight is always known precisely, and the objects are drawn as a fixed-radius
|
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,
|
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,
|
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
|
and left `null` — with its `distanceMethod` — whenever neither is trustworthy. 340 of
|
||||||
the 463 cataloged objects get a distance; the rest honestly report none.
|
the 488 cataloged objects get a distance; the rest honestly report none.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -324,7 +343,7 @@ plugin's own files are kept so it can be listed from a marketplace of its own la
|
|||||||
## Data credits
|
## Data credits
|
||||||
|
|
||||||
Star catalogue: [HYG database](https://github.com/astronexus/HYG-Database) (Hipparcos, Yale
|
Star catalogue: [HYG database](https://github.com/astronexus/HYG-Database) (Hipparcos, Yale
|
||||||
Bright Star, Gliese) — 68 388 stars within 250 pc. Solar-system ephemerides: NASA/JPL Horizons. Exoplanets: NASA Exoplanet
|
Bright Star, Gliese) — 68 388 stars within 250 pc. Solar-system orbits: JPL approximate planetary mean elements (Standish), JPL SSD satellite mean elements and the JPL Small-Body Database; rotation: the IAU WGCCRE 2015 report via NAIF's pck00011, with a locked moon's W and its pole's terms within 5 per cent of its node's rate re-rated to its JPL mean elements (but the Moon's and Phobos's) and Iapetus's pole carried round its orbit normal, and for Earth the IERS Conventions 2010; physical data, and the positions the orbits are checked against: NASA/JPL Horizons. Exoplanets: NASA Exoplanet
|
||||||
Archive. Deep-sky objects: [OpenNGC](https://github.com/mattiaverga/OpenNGC). Body and skybox
|
Archive. Deep-sky objects: [OpenNGC](https://github.com/mattiaverga/OpenNGC). Body and skybox
|
||||||
imagery: NASA/JPL/USGS public domain and Solar System Scope (CC BY 4.0) — per-file provenance
|
imagery: NASA/JPL/USGS public domain and Solar System Scope (CC BY 4.0) — per-file provenance
|
||||||
is recorded in `src/app/shared/rendering/texture-catalog.ts`.
|
is recorded in `src/assets/textures/README.md`.
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
import * as THREE from 'three/webgpu';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { DataLoaderService, StarField } from '../../core/data/data-loader.service';
|
||||||
|
import { EngineService, EngineTickCallback } from '../../core/engine/engine.service';
|
||||||
|
import { BodyRecord } from '../../shared/models/body.model';
|
||||||
|
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||||
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
|
import { bodyPageView } from '../../shared/rendering/body-orientation';
|
||||||
|
import { TimeStore } from '../../shared/state/time.store';
|
||||||
|
import { BodyDetailSceneComponent } from './body-detail-scene.component';
|
||||||
|
|
||||||
|
// jsdom has no ResizeObserver; the page only uses it to follow real layout changes.
|
||||||
|
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver ??= class {
|
||||||
|
observe(): void {}
|
||||||
|
disconnect(): void {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUN: StarRecord = { id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 };
|
||||||
|
|
||||||
|
// Earth and Saturn as bodies.json carries them: Standish's elements and the IAU's.
|
||||||
|
const EARTH: BodyRecord = {
|
||||||
|
id: 'earth', systemStarId: 0, name: 'Earth', kind: 'planet', radiusKm: 6371, orbitSource: 'test',
|
||||||
|
orbit: {semiMajorAxisAu: 1.00000018, eccentricity: 0.01673163, inclinationDeg: -0.00054346, longitudeOfAscendingNodeDeg: -5.11260389, argumentOfPeriapsisDeg: 108.04266274, meanAnomalyAtEpochDeg: -2.4631431299999917, epochJd: 2451545},
|
||||||
|
rates: {meanMotionDegPerDay: 0.9856091187759068, longitudeOfAscendingNodeDegPerDay: -0.000006604751813826146, argumentOfPeriapsisDegPerDay: 0.000015309819575633124},
|
||||||
|
rotationalElements: {poleRaDeg: [0, -0.641, 0], poleDecDeg: [90, -0.557, 0], primeMeridianDeg: [190.147, 360.9856235, 0]}
|
||||||
|
};
|
||||||
|
const SATURN: BodyRecord = {
|
||||||
|
id: 'saturn', systemStarId: 0, name: 'Saturn', kind: 'planet', radiusKm: 58232, orbitSource: 'test',
|
||||||
|
orbit: {semiMajorAxisAu: 9.54149883, eccentricity: 0.05550825, inclinationDeg: 2.49424102, longitudeOfAscendingNodeDeg: 113.63998702, argumentOfPeriapsisDeg: -20.778626390000014, meanAnomalyAtEpochDeg: -42.78564733999999, epochJd: 2451545},
|
||||||
|
rates: {meanMotionDegPerDay: 0.033459683702669406, longitudeOfAscendingNodeDegPerDay: -0.000006848734291581108, argumentOfPeriapsisDegPerDay: 0.000021682266940451745},
|
||||||
|
rotationalElements: {poleRaDeg: [40.589, -0.036, 0], poleDecDeg: [83.537, -0.004, 0], primeMeridianDeg: [38.9, 810.7939024, 0]}
|
||||||
|
};
|
||||||
|
// Eris and Hyperion as they are shipped for this page's purposes: no IAU model, so their pages keep
|
||||||
|
// their own light; Eris's day is measured, and Hyperion tumbles and has none.
|
||||||
|
const ERIS: BodyRecord = { ...EARTH, id: 'eris', name: 'Eris', kind: 'dwarf', radiusKm: 1163, rotationalElements: undefined, rotationPeriodHours: 378.504 };
|
||||||
|
const HYPERION: BodyRecord = { ...ERIS, id: 'hyperion', name: 'Hyperion', kind: 'moon', radiusKm: 135, parentBodyId: 'saturn', rotationPeriodHours: undefined };
|
||||||
|
// Mercury as shipped, but its 0.01-degree libration: its Sun is never 0.034 degrees off its equator.
|
||||||
|
const MERCURY: BodyRecord = {
|
||||||
|
id: 'mercury', systemStarId: 0, name: 'Mercury', kind: 'planet', radiusKm: 2439.4, orbitSource: 'test',
|
||||||
|
orbit: {semiMajorAxisAu: 0.38709843, eccentricity: 0.20563661, inclinationDeg: 7.00559432, longitudeOfAscendingNodeDeg: 48.33961819, argumentOfPeriapsisDeg: 29.118100759999997, meanAnomalyAtEpochDeg: 174.79394829, epochJd: 2451545},
|
||||||
|
rates: {meanMotionDegPerDay: 4.092338805372484, longitudeOfAscendingNodeDegPerDay: -0.0000033440607802874744, argumentOfPeriapsisDegPerDay: 0.000007708198494182067},
|
||||||
|
rotationalElements: {poleRaDeg: [281.0103, -0.0328, 0], poleDecDeg: [61.4155, -0.0049, 0], primeMeridianDeg: [329.5988, 6.1385108, 0]}
|
||||||
|
};
|
||||||
|
const BODIES = [EARTH, SATURN, ERIS, HYPERION, MERCURY];
|
||||||
|
// An exoplanet round the Sun's record, which is all the page needs of its host.
|
||||||
|
const EXOPLANET: ExoplanetRecord = { id: 'x b', hostStarId: 0, hostStarName: 'Sol', name: 'X b', orbit: { semiMajorAxisAu: 0.05 } };
|
||||||
|
|
||||||
|
/** Stands in for the WebGPU engine: a scene, a camera, and the tick hook, driven by hand. */
|
||||||
|
class FakeEngineService {
|
||||||
|
private readonly scene = new THREE.Scene();
|
||||||
|
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
|
||||||
|
private readonly callbacks = new Set<EngineTickCallback>();
|
||||||
|
|
||||||
|
async init(): Promise<void> {}
|
||||||
|
getScene(): THREE.Scene {
|
||||||
|
return this.scene;
|
||||||
|
}
|
||||||
|
getCamera(): THREE.PerspectiveCamera {
|
||||||
|
return this.camera;
|
||||||
|
}
|
||||||
|
onTick(callback: EngineTickCallback): () => void {
|
||||||
|
this.callbacks.add(callback);
|
||||||
|
return () => this.callbacks.delete(callback);
|
||||||
|
}
|
||||||
|
start(): void {}
|
||||||
|
resize(): void {}
|
||||||
|
dispose(): void {}
|
||||||
|
tick(deltaSeconds: number): void {
|
||||||
|
for (const callback of this.callbacks) {
|
||||||
|
callback(deltaSeconds, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDataLoaderService {
|
||||||
|
loadStars(): Promise<StarField> {
|
||||||
|
return Promise.resolve({ stars: [SUN], positions: new Float32Array([0, 0, 0]) });
|
||||||
|
}
|
||||||
|
loadBodies(): Promise<BodyRecord[]> {
|
||||||
|
return Promise.resolve(BODIES);
|
||||||
|
}
|
||||||
|
loadExoplanets(): Promise<ExoplanetRecord[]> {
|
||||||
|
return Promise.resolve([EXOPLANET]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushAsync(turns = 8): Promise<void> {
|
||||||
|
for (let i = 0; i < turns; i++) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BodyDetailSceneComponent', () => {
|
||||||
|
let engine: FakeEngineService;
|
||||||
|
let page: { planet: THREE.Mesh; ring?: THREE.Mesh; sunLight: THREE.DirectionalLight };
|
||||||
|
let time: TimeStore;
|
||||||
|
let route: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||||
|
|
||||||
|
let fixture: ComponentFixture<BodyDetailSceneComponent>;
|
||||||
|
|
||||||
|
/** Opens a body's page at a date; `whole` keeps the page's own template, dock and panel included. */
|
||||||
|
async function open(id: string, date = '2025-06-01T12:00Z', whole = false): Promise<void> {
|
||||||
|
engine = new FakeEngineService();
|
||||||
|
route = new BehaviorSubject(convertToParamMap({ id }));
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [BodyDetailSceneComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: DataLoaderService, useClass: FakeDataLoaderService },
|
||||||
|
{ provide: ActivatedRoute, useValue: { paramMap: route } },
|
||||||
|
{ provide: Router, useValue: { navigate: vi.fn().mockResolvedValue(true) } }
|
||||||
|
]
|
||||||
|
}).overrideComponent(BodyDetailSceneComponent, {
|
||||||
|
// The scene alone, unless asked: the info panel and the dock are tested on their own.
|
||||||
|
set: whole ? { providers: [{ provide: EngineService, useValue: engine }] } : { providers: [{ provide: EngineService, useValue: engine }], imports: [], template: '<canvas #canvas></canvas>' }
|
||||||
|
});
|
||||||
|
time = TestBed.inject(TimeStore);
|
||||||
|
time.setRate(0);
|
||||||
|
time.setDate(new Date(date));
|
||||||
|
fixture = TestBed.createComponent(BodyDetailSceneComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await flushAsync();
|
||||||
|
page = fixture.componentInstance as unknown as typeof page;
|
||||||
|
engine.tick(0.016);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => TestBed.resetTestingModule());
|
||||||
|
|
||||||
|
it('lays Saturn’s rings in its equator on the page, where audit #47 found them 17 degrees off it', async () => {
|
||||||
|
await open('saturn');
|
||||||
|
const ring = page.ring!;
|
||||||
|
ring.updateWorldMatrix(true, false);
|
||||||
|
const normal = new THREE.Vector3().fromBufferAttribute(ring.geometry.attributes['normal'], 0).transformDirection(ring.matrixWorld);
|
||||||
|
const pole = new THREE.Vector3(0, 1, 0).applyQuaternion(page.planet.quaternion);
|
||||||
|
expect(normal.angleTo(pole)).toBeLessThan(1e-6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns Earth on its page as it stands at the map’s date, under its real Sun', async () => {
|
||||||
|
await open('earth');
|
||||||
|
const planet = new THREE.Quaternion();
|
||||||
|
const sun = new THREE.Vector3();
|
||||||
|
expect(bodyPageView(EARTH, BODIES, time.julianDate(), Math.atan2(4, 5), planet, sun)).toBe(true);
|
||||||
|
expect(page.planet.quaternion.angleTo(planet)).toBeLessThan(1e-9);
|
||||||
|
expect(page.sunLight.position.clone().normalize().angleTo(sun)).toBeLessThan(1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows the clock once the page is open, as it runs or is set', async () => {
|
||||||
|
await open('earth');
|
||||||
|
time.setDate(new Date('2025-06-01T18:00Z'));
|
||||||
|
engine.tick(0.016);
|
||||||
|
const planet = new THREE.Quaternion();
|
||||||
|
expect(bodyPageView(EARTH, BODIES, time.julianDate(), Math.atan2(4, 5), planet, new THREE.Vector3())).toBe(true);
|
||||||
|
// Six hours on, a quarter turn of Earth: a page frozen at its first frame is 90 degrees out.
|
||||||
|
expect(page.planet.quaternion.angleTo(planet)).toBeLessThan(1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the page’s own light back when the next body shown has no IAU model to place its Sun', async () => {
|
||||||
|
await open('earth');
|
||||||
|
expect(page.sunLight.position.distanceTo(new THREE.Vector3(4, 3, 5))).toBeGreaterThan(0.1);
|
||||||
|
route.next(convertToParamMap({ id: 'eris' }));
|
||||||
|
await flushAsync();
|
||||||
|
engine.tick(0.016);
|
||||||
|
expect(page.sunLight.position.distanceTo(new THREE.Vector3(4, 3, 5))).toBeLessThan(1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the sphere back at rest when the next body shown does not turn: Hyperion after Earth', async () => {
|
||||||
|
await open('earth');
|
||||||
|
expect(page.planet.quaternion.angleTo(new THREE.Quaternion())).toBeGreaterThan(0.1);
|
||||||
|
route.next(convertToParamMap({ id: 'hyperion' }));
|
||||||
|
await flushAsync();
|
||||||
|
engine.tick(0.016);
|
||||||
|
engine.tick(0.016);
|
||||||
|
expect(page.planet.quaternion.angleTo(new THREE.Quaternion())).toBeLessThan(1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns a body whose day is measured but not its pole at that day on the map’s clock: Eris a sixth of a turn in 63.084 hours', async () => {
|
||||||
|
await open('eris');
|
||||||
|
const start = page.planet.rotation.y;
|
||||||
|
// The clock stands (the page is opened at rate 0): so does Eris, where it used to turn for show.
|
||||||
|
engine.tick(1);
|
||||||
|
expect(page.planet.rotation.y).toBe(start);
|
||||||
|
time.setDate(new Date(Date.parse('2025-06-01T12:00Z') + (378.504 / 6) * 3600000));
|
||||||
|
engine.tick(0.016);
|
||||||
|
const turned = (((page.planet.rotation.y - start) / (2 * Math.PI)) % 1 + 1) % 1;
|
||||||
|
expect(turned).toBeCloseTo(1 / 6, 6);
|
||||||
|
// Pole up, as the system view turns it about its orbit's normal.
|
||||||
|
expect(new THREE.Vector3(0, 1, 0).applyQuaternion(page.planet.quaternion).y).toBeCloseTo(1, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns an exoplanet slowly for show, clock or no clock: the catalogue carries no day for it', async () => {
|
||||||
|
await open('x b');
|
||||||
|
const start = page.planet.rotation.y;
|
||||||
|
// The clock stands; a second of the page's own time is 0.08 radians.
|
||||||
|
engine.tick(1);
|
||||||
|
expect(page.planet.rotation.y - start).toBeCloseTo(0.08, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says on its dock the date the body is drawn for, and nothing at the present, and offers the clock', async () => {
|
||||||
|
await open('saturn', '2032-06-01T12:00Z', true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const host = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(host.querySelector('[data-testid="hud-date"]')?.textContent).toContain('2032-06-01');
|
||||||
|
expect([...host.querySelectorAll('[role="tab"]')].map((tab) => tab.textContent?.trim())).toContain('Clock');
|
||||||
|
time.reset();
|
||||||
|
engine.tick(0.016);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(host.querySelector('[data-testid="hud-date"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens Saturn on the face of its rings the Sun lights: the south, from 2025 to 2039', async () => {
|
||||||
|
await open('saturn', '2032-06-01T12:00Z');
|
||||||
|
const camera = engine.getCamera();
|
||||||
|
// The Sun 26.7 degrees south of the rings, and the camera with it rather than 11 degrees north.
|
||||||
|
expect(page.sunLight.position.y).toBeLessThan(0);
|
||||||
|
expect(camera.position.y).toBeLessThan(0);
|
||||||
|
|
||||||
|
route.next(convertToParamMap({ id: 'earth' }));
|
||||||
|
await flushAsync();
|
||||||
|
engine.tick(0.016);
|
||||||
|
// June: Earth's Sun is in the north, and so is the camera again.
|
||||||
|
expect(page.sunLight.position.y).toBeGreaterThan(0);
|
||||||
|
expect(camera.position.y).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows the Sun across Saturn’s equator when the clock is set past the 2039 equinox, and aims at Saturn in that same frame', async () => {
|
||||||
|
await open('saturn', '2032-06-01T12:00Z');
|
||||||
|
const camera = engine.getCamera();
|
||||||
|
expect(camera.position.y).toBeLessThan(0);
|
||||||
|
// What the page's own Clock tab does: 2045, the Sun 25.7 degrees north of the rings.
|
||||||
|
time.setDate(new Date('2045-06-01T12:00Z'));
|
||||||
|
engine.tick(0.016);
|
||||||
|
expect(page.sunLight.position.y).toBeGreaterThan(0);
|
||||||
|
expect(camera.position.y).toBeGreaterThan(0);
|
||||||
|
// The frame drawn straight after the move: aimed from where the camera was, it had Saturn 22.6
|
||||||
|
// degrees off the middle of the view.
|
||||||
|
const toSaturn = new THREE.Vector3().sub(camera.position);
|
||||||
|
expect(camera.getWorldDirection(new THREE.Vector3()).angleTo(toSaturn)).toBeLessThan(1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the next body shown on its own Sun’s side, wherever the reader left the camera: Earth after Saturn in December', async () => {
|
||||||
|
await open('saturn', '2032-12-01T12:00Z');
|
||||||
|
const camera = engine.getCamera();
|
||||||
|
expect(camera.position.y).toBeLessThan(0);
|
||||||
|
// Taken north by the reader, over Saturn's unlit ring face.
|
||||||
|
camera.position.y = 0.6;
|
||||||
|
engine.tick(0.016);
|
||||||
|
expect(camera.position.y).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
route.next(convertToParamMap({ id: 'earth' }));
|
||||||
|
await flushAsync();
|
||||||
|
engine.tick(0.016);
|
||||||
|
// December: Earth's Sun is south, as Saturn's was, so only the side chosen afresh moves the camera.
|
||||||
|
expect(page.sunLight.position.y).toBeLessThan(0);
|
||||||
|
expect(camera.position.y).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the camera on its side while the Sun only grazes the equator: Mercury through two crossings', async () => {
|
||||||
|
// The Sun is south of Mercury's equator on 2026-10-20, north from about 1 November, and south
|
||||||
|
// again from about 6 December, never more than 0.034 degrees either side.
|
||||||
|
await open('mercury', '2026-10-20T00:00Z');
|
||||||
|
const camera = engine.getCamera();
|
||||||
|
expect(page.sunLight.position.y).toBeLessThan(0);
|
||||||
|
expect(camera.position.y).toBeLessThan(0);
|
||||||
|
const sunSides = new Set<number>();
|
||||||
|
for (let day = 1; day <= 60; day++) {
|
||||||
|
time.setDate(new Date(Date.parse('2026-10-20T00:00Z') + day * 86400000));
|
||||||
|
engine.tick(0.016);
|
||||||
|
sunSides.add(Math.sign(page.sunLight.position.y));
|
||||||
|
expect(camera.position.y).toBeLessThan(0);
|
||||||
|
}
|
||||||
|
expect([...sunSides].sort()).toEqual([-1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the camera where the reader orbits it while the Sun stays on one side', async () => {
|
||||||
|
await open('saturn', '2032-06-01T12:00Z');
|
||||||
|
const camera = engine.getCamera();
|
||||||
|
// Taken over the rings, to their unlit face, on purpose.
|
||||||
|
camera.position.y = 0.6;
|
||||||
|
engine.tick(0.016);
|
||||||
|
expect(camera.position.y).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,14 +6,16 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|||||||
|
|
||||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||||
import { EngineService } from '../../core/engine/engine.service';
|
import { EngineService } from '../../core/engine/engine.service';
|
||||||
|
import { bodyPageView } from '../../shared/rendering/body-orientation';
|
||||||
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
||||||
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
|
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
|
||||||
import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SATURN_RING_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
|
import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, saturnRing } from '../../shared/rendering/texture-catalog';
|
||||||
import { BodyRecord } from '../../shared/models/body.model';
|
import { BodyRecord } from '../../shared/models/body.model';
|
||||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||||
import { StarRecord } from '../../shared/models/star.model';
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
import { Bookmark } from '../../shared/state/bookmarks.store';
|
import { Bookmark } from '../../shared/state/bookmarks.store';
|
||||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||||
|
import { TimeStore } from '../../shared/state/time.store';
|
||||||
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
||||||
import { HudDockComponent } from '../hud/hud-dock.component';
|
import { HudDockComponent } from '../hud/hud-dock.component';
|
||||||
import { BodyDetailViewModel } from './body-detail.model';
|
import { BodyDetailViewModel } from './body-detail.model';
|
||||||
@@ -24,6 +26,18 @@ import { InfoPanelComponent } from './info-panel.component';
|
|||||||
const GAS_GIANT_IDS = new Set(['jupiter', 'saturn', 'uranus', 'neptune']);
|
const GAS_GIANT_IDS = new Set(['jupiter', 'saturn', 'uranus', 'neptune']);
|
||||||
/** The body is drawn at unit radius here, so the halo's extent is its multiple directly. */
|
/** The body is drawn at unit radius here, so the halo's extent is its multiple directly. */
|
||||||
const GLOW_SCALE = 2.6;
|
const GLOW_SCALE = 2.6;
|
||||||
|
/** Where the page's light stands, and the Sun with it wherever the body's real one is known. */
|
||||||
|
const SUN_LIGHT_POSITION = new THREE.Vector3(4, 3, 5);
|
||||||
|
/**
|
||||||
|
* How far from the equator the Sun must stand, as the sine of its latitude, before the camera
|
||||||
|
* follows it across: 3 degrees. The side is for Saturn's rings, lit on one face only, whose Sun
|
||||||
|
* goes 26.7 degrees either side. Mercury's never leaves the equator by more than 0.034 degrees
|
||||||
|
* and crosses it 8.3 times a year, which moved the camera from one side to the other every 1.45
|
||||||
|
* seconds at a month a second, both sides lit alike; Venus's reaches 2.6 and the Moon's 1.6.
|
||||||
|
* Earth's and Saturn's pages still follow their seasons, a week and half a year after each
|
||||||
|
* equinox (2025-03-28 and 2039-08-03, measured).
|
||||||
|
*/
|
||||||
|
const SUN_SIDE_MIN_SINE = Math.sin((3 * Math.PI) / 180);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Separate, focused route for inspecting a single planet/moon/exoplanet: its own scene/camera
|
* Separate, focused route for inspecting a single planet/moon/exoplanet: its own scene/camera
|
||||||
@@ -58,9 +72,12 @@ const GLOW_SCALE = 2.6;
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Search and what has been kept: there is no scene readout here, the info panel is
|
<!-- Search, what has been kept and the clock: there is no scene readout here, the info
|
||||||
the reading, and the panel's own control is what keeps this body. -->
|
panel is the reading, and the panel's own control is what keeps this body. A solar-system
|
||||||
<app-hud-dock (bookmarkChosen)="goToBookmark($event)" />
|
body is drawn at the clock's date and turns at its rate, so both are shown and can be set
|
||||||
|
here; an exoplanet, whose day the catalogue does not carry, turns for show whatever the
|
||||||
|
clock says. -->
|
||||||
|
<app-hud-dock [date]="date()" [clock]="true" (bookmarkChosen)="goToBookmark($event)" />
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
@@ -81,6 +98,9 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
private scene?: THREE.Scene;
|
private scene?: THREE.Scene;
|
||||||
private planet?: THREE.Mesh;
|
private planet?: THREE.Mesh;
|
||||||
private planetMaterial?: THREE.MeshStandardMaterial;
|
private planetMaterial?: THREE.MeshStandardMaterial;
|
||||||
|
private sunLight?: THREE.DirectionalLight;
|
||||||
|
/** The solar-system record behind the body shown, which is what can be turned by its real pole. */
|
||||||
|
private body?: BodyRecord;
|
||||||
private ring?: THREE.Mesh;
|
private ring?: THREE.Mesh;
|
||||||
private glow?: THREE.Sprite;
|
private glow?: THREE.Sprite;
|
||||||
private resizeObserver?: ResizeObserver;
|
private resizeObserver?: ResizeObserver;
|
||||||
@@ -94,13 +114,18 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
readonly viewModel = signal<BodyDetailViewModel | undefined>(undefined);
|
readonly viewModel = signal<BodyDetailViewModel | undefined>(undefined);
|
||||||
readonly notFound = signal(false);
|
readonly notFound = signal(false);
|
||||||
|
/** The date the body is drawn for, as the dock's strip prints it; empty at the present. */
|
||||||
|
readonly date = signal('');
|
||||||
|
/** The side of the equator the Sun stood on at the last frame, 1 north or -1 south; 0 once a body is shown. */
|
||||||
|
private sunSide = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly engine: EngineService,
|
private readonly engine: EngineService,
|
||||||
private readonly dataLoader: DataLoaderService,
|
private readonly dataLoader: DataLoaderService,
|
||||||
private readonly route: ActivatedRoute,
|
private readonly route: ActivatedRoute,
|
||||||
private readonly router: Router,
|
private readonly router: Router,
|
||||||
private readonly navigationStore: NavigationStore
|
private readonly navigationStore: NavigationStore,
|
||||||
|
private readonly time: TimeStore
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
@@ -167,8 +192,8 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Real photography wherever it exists, and a surface derived from the body's own measured
|
// Real photography wherever it exists, and a surface derived from the body's own measured
|
||||||
// properties wherever it does not — which is every exoplanet, since none has ever been
|
// properties wherever it does not — which is every exoplanet, since none has had its
|
||||||
// imaged, and the handful of moons no probe returned a usable map of.
|
// surface imaged, and the handful of moons no probe returned a usable map of.
|
||||||
const realTexturePath = bodyTexturePath(viewModel.id);
|
const realTexturePath = bodyTexturePath(viewModel.id);
|
||||||
this.planetMaterial.map = realTexturePath ? loadCachedTexture(realTexturePath) : planetTexture(viewModel.appearance);
|
this.planetMaterial.map = realTexturePath ? loadCachedTexture(realTexturePath) : planetTexture(viewModel.appearance);
|
||||||
// The texture supplies its own colour, so the base stays white rather than tinting it twice.
|
// The texture supplies its own colour, so the base stays white rather than tinting it twice.
|
||||||
@@ -176,12 +201,19 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
// A fluid envelope scatters light more evenly than a solid surface does.
|
// A fluid envelope scatters light more evenly than a solid surface does.
|
||||||
this.planetMaterial.roughness = GAS_GIANT_IDS.has(viewModel.id) || viewModel.appearance.palette.structure === 'banded' ? 0.55 : 0.85;
|
this.planetMaterial.roughness = GAS_GIANT_IDS.has(viewModel.id) || viewModel.appearance.palette.structure === 'banded' ? 0.55 : 0.85;
|
||||||
this.planetMaterial.needsUpdate = true;
|
this.planetMaterial.needsUpdate = true;
|
||||||
|
// Back to the page's own light and a sphere at rest; `tick` turns both where the IAU says how.
|
||||||
|
this.body = this.bodies.find((body) => body.id === viewModel.id);
|
||||||
|
this.planet?.rotation.set(0, 0, 0);
|
||||||
|
this.sunLight?.position.copy(SUN_LIGHT_POSITION);
|
||||||
|
this.sunSide = 0;
|
||||||
|
|
||||||
this.disposeRing();
|
this.disposeRing();
|
||||||
this.disposeGlow();
|
this.disposeGlow();
|
||||||
if (this.scene) {
|
if (this.scene) {
|
||||||
if (viewModel.id === 'saturn') {
|
if (viewModel.id === 'saturn' && this.body) {
|
||||||
this.ring = this.buildSaturnRing();
|
// Flat in the page's horizontal, which is Saturn's equator: the planet is drawn pole up, at
|
||||||
|
// unit radius. They used to reach 2.6 radii out; the outermost ring the texture draws is 2.42.
|
||||||
|
this.ring = saturnRing(this.body.radiusKm, 1);
|
||||||
this.scene.add(this.ring);
|
this.scene.add(this.ring);
|
||||||
}
|
}
|
||||||
const atmosphereColor = atmosphereColorFor(viewModel.id);
|
const atmosphereColor = atmosphereColorFor(viewModel.id);
|
||||||
@@ -192,37 +224,6 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Saturn's rings, built from a real ring-transparency map. `RingGeometry`'s default UVs wrap
|
|
||||||
* around the angle rather than the radius, so the per-vertex U is remapped to distance from
|
|
||||||
* center — the standard fix for sampling a radially-varying ring texture correctly.
|
|
||||||
*/
|
|
||||||
private buildSaturnRing(): THREE.Mesh {
|
|
||||||
const geometry = new THREE.RingGeometry(1.4, 2.6, 128, 1);
|
|
||||||
const position = geometry.attributes['position'];
|
|
||||||
const uv = geometry.attributes['uv'];
|
|
||||||
const vertex = new THREE.Vector3();
|
|
||||||
for (let i = 0; i < position.count; i++) {
|
|
||||||
vertex.fromBufferAttribute(position, i);
|
|
||||||
const radialFraction = THREE.MathUtils.clamp((vertex.length() - 1.4) / (2.6 - 1.4), 0, 1);
|
|
||||||
uv.setXY(i, radialFraction, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ringTexture = loadCachedTexture(SATURN_RING_TEXTURE_PATH);
|
|
||||||
const material = new THREE.MeshBasicMaterial({
|
|
||||||
map: ringTexture,
|
|
||||||
alphaMap: ringTexture,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.85,
|
|
||||||
side: THREE.DoubleSide,
|
|
||||||
depthWrite: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const ring = new THREE.Mesh(geometry, material);
|
|
||||||
ring.rotation.x = Math.PI / 2 - THREE.MathUtils.degToRad(17);
|
|
||||||
return ring;
|
|
||||||
}
|
|
||||||
|
|
||||||
private disposeRing(): void {
|
private disposeRing(): void {
|
||||||
if (!this.ring) {
|
if (!this.ring) {
|
||||||
return;
|
return;
|
||||||
@@ -268,9 +269,9 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.controls.maxDistance = 12;
|
this.controls.maxDistance = 12;
|
||||||
|
|
||||||
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
|
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
|
||||||
const sunLight = new THREE.DirectionalLight(0xfff4e0, 1.6);
|
this.sunLight = new THREE.DirectionalLight(0xfff4e0, 1.6);
|
||||||
sunLight.position.set(4, 3, 5);
|
this.sunLight.position.copy(SUN_LIGHT_POSITION);
|
||||||
scene.add(sunLight);
|
scene.add(this.sunLight);
|
||||||
|
|
||||||
const geometry = new THREE.SphereGeometry(1, 64, 48);
|
const geometry = new THREE.SphereGeometry(1, 64, 48);
|
||||||
const viewModel = this.viewModel();
|
const viewModel = this.viewModel();
|
||||||
@@ -289,11 +290,46 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.engine.start();
|
this.engine.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A body the IAU gives rotational elements for is turned as it is at the map's date, under its
|
||||||
|
* real Sun, at the rate the map's clock runs (see `bodyPageView`). Eris, Haumea, Makemake and
|
||||||
|
* Nereid, whose day is measured but whose pole is not, turn pole up at that day on the same
|
||||||
|
* clock, as the system view turns them; Hyperion, which tumbles, is left still, as it is there.
|
||||||
|
* An exoplanet turns slowly for show, as the page always turned it.
|
||||||
|
*/
|
||||||
private tick(deltaSeconds: number): void {
|
private tick(deltaSeconds: number): void {
|
||||||
this.controls?.update();
|
this.controls?.update();
|
||||||
if (this.planet) {
|
this.date.set(this.time.atNow() ? '' : this.time.date().toISOString().slice(0, 10));
|
||||||
|
if (!this.planet || !this.sunLight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sunAzimuth = Math.atan2(SUN_LIGHT_POSITION.x, SUN_LIGHT_POSITION.z);
|
||||||
|
if (this.body && bodyPageView(this.body, this.bodies, this.time.julianDate(), sunAzimuth, this.planet.quaternion, this.sunLight.position)) {
|
||||||
|
this.sunLight.position.multiplyScalar(SUN_LIGHT_POSITION.length());
|
||||||
|
} else if (this.body?.rotationPeriodHours !== undefined) {
|
||||||
|
// Counted from the orbit's epoch, as `spinFor` counts: where the meridian starts is unknown.
|
||||||
|
const turns = ((this.time.julianDate() - this.body.orbit.epochJd) * 24) / this.body.rotationPeriodHours;
|
||||||
|
this.planet.rotation.set(0, (turns % 1) * 2 * Math.PI, 0);
|
||||||
|
} else if (!this.body) {
|
||||||
this.planet.rotation.y += deltaSeconds * 0.08;
|
this.planet.rotation.y += deltaSeconds * 0.08;
|
||||||
}
|
}
|
||||||
|
const sunLatitudeSine = this.sunLight.position.y / this.sunLight.position.length();
|
||||||
|
const sunSide = this.sunSide !== 0 && Math.abs(sunLatitudeSine) < SUN_SIDE_MIN_SINE ? this.sunSide : sunLatitudeSine < 0 ? -1 : 1;
|
||||||
|
if (sunSide !== this.sunSide) {
|
||||||
|
// Above or below the equator, whichever side the Sun is on, when a body is shown and again
|
||||||
|
// whenever the Sun is well across it (SUN_SIDE_MIN_SINE), as the clock runs or is set: held
|
||||||
|
// above it, the page opened Saturn on the unlit face of its rings from 2025 until 2039, while
|
||||||
|
// the Sun is south of them — the face Earth does not see either — and the Clock set to 2045
|
||||||
|
// left it on the other one. Between crossings the camera is the reader's to orbit where they like.
|
||||||
|
this.sunSide = sunSide;
|
||||||
|
const camera = this.engine.getCamera();
|
||||||
|
camera.position.y = Math.abs(camera.position.y) * sunSide;
|
||||||
|
// Aimed again before this frame is drawn: the controls aimed it from where it was, and the
|
||||||
|
// frame drawn from here otherwise had the body 22.6 degrees off the middle of the view.
|
||||||
|
if (this.controls) {
|
||||||
|
camera.lookAt(this.controls.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private observeResize(canvas: HTMLCanvasElement): void {
|
private observeResize(canvas: HTMLCanvasElement): void {
|
||||||
|
|||||||
@@ -20,22 +20,25 @@ export interface BodyDetailViewModel {
|
|||||||
*/
|
*/
|
||||||
hostStarId?: number;
|
hostStarId?: number;
|
||||||
radiusKm?: number;
|
radiusKm?: number;
|
||||||
|
/** A triaxial body's semi-axes, where `radiusKm` is the mean of them; see `BodyRecord.semiAxesKm`. */
|
||||||
|
semiAxesKm?: readonly [number, number, number];
|
||||||
massEarth?: number;
|
massEarth?: number;
|
||||||
discoveryYear?: number;
|
discoveryYear?: number;
|
||||||
orbit: Partial<OrbitalElements>;
|
orbit: Partial<OrbitalElements>;
|
||||||
/**
|
/**
|
||||||
* What this world is inferred to look like, and the quantities that inference rests on. Always
|
* 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
|
* 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
|
* fields are nullable, since a body whose host star's luminosity or whose orbit's size is not
|
||||||
* temperature.
|
* known has no derived temperature.
|
||||||
*/
|
*/
|
||||||
appearance: PlanetAppearance;
|
appearance: PlanetAppearance;
|
||||||
/** True when a real photograph is being shown rather than the derived surface. */
|
/** True when a real photograph is being shown rather than the derived surface. */
|
||||||
hasPhotography: boolean;
|
hasPhotography: boolean;
|
||||||
|
/** An exoplanet photographed by direct imaging, as a point of light; see `ExoplanetRecord.imaged`. */
|
||||||
|
imaged?: boolean;
|
||||||
/**
|
/**
|
||||||
* Sidereal orbital period. Measured where the archive published one; otherwise derived from the
|
* Sidereal orbital period. For a solar-system body, 360 degrees over JPL's published mean
|
||||||
* semi-major axis for heliocentric orbits, where the central mass is known exactly. Undefined
|
* motion; for an exoplanet, the archive's period where it published one, and undefined where not.
|
||||||
* when neither applies — see `heliocentricPeriodDays`.
|
|
||||||
*/
|
*/
|
||||||
orbitalPeriodDays?: number;
|
orbitalPeriodDays?: number;
|
||||||
/**
|
/**
|
||||||
@@ -44,4 +47,6 @@ export interface BodyDetailViewModel {
|
|||||||
* derived surface as a photograph.
|
* derived surface as a photograph.
|
||||||
*/
|
*/
|
||||||
orbitalPeriodSource?: 'measured' | 'derived';
|
orbitalPeriodSource?: 'measured' | 'derived';
|
||||||
|
/** Where the orbit comes from and the span it holds over; see `BodyRecord.orbitSource`. */
|
||||||
|
orbitSource?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ export interface BodyReadouts {
|
|||||||
export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts {
|
export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts {
|
||||||
const measured: Readout[] = [];
|
const measured: Readout[] = [];
|
||||||
if (body.radiusKm !== undefined) {
|
if (body.radiusKm !== undefined) {
|
||||||
measured.push({ label: 'Radius', value: formatRadiusKm(body.radiusKm) });
|
// A triaxial body is drawn as the sphere of its volume; a radius alone would hide its shape.
|
||||||
|
measured.push({ label: body.semiAxesKm ? 'Mean radius' : 'Radius', value: formatRadiusKm(body.radiusKm) });
|
||||||
|
}
|
||||||
|
if (body.semiAxesKm) {
|
||||||
|
measured.push({ label: 'Semi-axes', value: `${body.semiAxesKm.map((axis) => axis.toLocaleString('en-GB')).join(' × ')} km` });
|
||||||
}
|
}
|
||||||
if (body.massEarth !== undefined) {
|
if (body.massEarth !== undefined) {
|
||||||
measured.push({ label: 'Mass', value: formatMassEarth(body.massEarth) });
|
measured.push({ label: 'Mass', value: formatMassEarth(body.massEarth) });
|
||||||
@@ -43,7 +47,9 @@ export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts {
|
|||||||
measured.push({ label: 'Eccentricity', value: body.orbit.eccentricity.toFixed(3) });
|
measured.push({ label: 'Eccentricity', value: body.orbit.eccentricity.toFixed(3) });
|
||||||
}
|
}
|
||||||
if (body.orbit.inclinationDeg !== undefined) {
|
if (body.orbit.inclinationDeg !== undefined) {
|
||||||
measured.push({ label: 'Inclination', value: `${body.orbit.inclinationDeg.toFixed(2)}°` });
|
// Its size: Standish fits Earth's as -0.00054 degrees, which is the same orbit as +0.00054 with
|
||||||
|
// the node half a turn round, and printed as it stands read "-0.00°".
|
||||||
|
measured.push({ label: 'Inclination', value: `${Math.abs(body.orbit.inclinationDeg).toFixed(2)}°` });
|
||||||
}
|
}
|
||||||
// The period sits under whichever heading its provenance calls for. Same number, same field —
|
// The period sits under whichever heading its provenance calls for. Same number, same field —
|
||||||
// a published period is an observation and a computed one is not.
|
// a published period is an observation and a computed one is not.
|
||||||
@@ -65,18 +71,39 @@ export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts {
|
|||||||
derived.push({ label: 'Bulk density', value: formatDensity(body.appearance.bulkDensityGramsPerCm3) });
|
derived.push({ label: 'Bulk density', value: formatDensity(body.appearance.bulkDensityGramsPerCm3) });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { kindLabel: KIND_LABELS[body.kind], measured, derived, provenance: provenanceFor(body) };
|
const provenance = body.orbitSource ? `${provenanceFor(body)} Orbit: ${body.orbitSource}.` : provenanceFor(body);
|
||||||
|
return { kindLabel: KIND_LABELS[body.kind], measured, derived, provenance };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The derived surface is a reasoned illustration, and a panel of real measurements sitting next
|
* The derived surface is a reasoned illustration, and a panel of real measurements sitting next
|
||||||
* to it is exactly the context in which it could be mistaken for another one.
|
* to it is exactly the context in which it could be mistaken for another one.
|
||||||
|
*
|
||||||
|
* With no temperature, it says what is missing without claiming which: the host's luminosity, or
|
||||||
|
* the orbit's size. It used to say the host was not in the catalogue, which is so for 27 of the
|
||||||
|
* 2 714 planets it was printed on. Of the rest, 2 420 have no semi-major axis, and since 869635b
|
||||||
|
* 267 have a host no survey measured the brightness of — OGLE-2005-BLG-390L b, read inside its
|
||||||
|
* own host's system.
|
||||||
|
*
|
||||||
|
* A moon or dwarf planet drawn this way has been imaged — Voyager 2 photographed Uranus's five
|
||||||
|
* large moons, Proteus and Nereid, Cassini Hyperion, and Hubble sees Eris, Haumea and Makemake as
|
||||||
|
* points — but has no global map this app can use. So have the hundred or so exoplanets the
|
||||||
|
* archive flags as imaged, HR 8799's four among them, though only as points of light beside their
|
||||||
|
* star — and one of them has a map, not used here: Luhman 16 b, a brown dwarf, mapped by Doppler
|
||||||
|
* imaging (Crossfield et al. 2014, Nature 505, 654). Only the other exoplanets, known from what they
|
||||||
|
* do to starlight, have no image at all.
|
||||||
*/
|
*/
|
||||||
function provenanceFor(body: BodyDetailViewModel): string {
|
function provenanceFor(body: BodyDetailViewModel): string {
|
||||||
if (body.hasPhotography) {
|
if (body.hasPhotography) {
|
||||||
return 'Surface: NASA/ESA/USGS photography.';
|
return 'Surface: NASA/ESA/USGS photography.';
|
||||||
}
|
}
|
||||||
|
const why =
|
||||||
|
body.kind !== 'exoplanet'
|
||||||
|
? 'no global map of this world is used here'
|
||||||
|
: body.imaged
|
||||||
|
? 'it has been imaged only as a point of light beside its star, and no map of it is used here'
|
||||||
|
: 'no image of this world exists';
|
||||||
return body.appearance.equilibriumTemperatureK === null
|
return body.appearance.equilibriumTemperatureK === null
|
||||||
? 'Surface illustrated from this body’s measured size and mass. Its host star is not in the catalogue, so no temperature could be derived. Not an observation — no image of this world exists.'
|
? `Surface illustrated from this body’s measured size and mass. No temperature could be derived: its star’s luminosity or its orbit’s size is not known. Not an observation — ${why}.`
|
||||||
: 'Surface illustrated from the measurements above — size, density and the temperature derived from its star’s output and its orbit. Not an observation — no image of this world exists.';
|
: `Surface illustrated from the measurements above — size, density and the temperature derived from its star’s output and its orbit. Not an observation — ${why}.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
|
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
|
||||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||||
import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model';
|
import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model';
|
||||||
import { buildBodyViewModel, heliocentricPeriodDays } from './body-view-model';
|
import { bodyReadouts } from './body-readouts';
|
||||||
|
import { buildBodyViewModel, luminosityOf, publishedTemperaturesK, starSurfaceOf } from './body-view-model';
|
||||||
|
|
||||||
const orbit = (overrides: Partial<OrbitalElements> = {}): OrbitalElements => ({
|
const orbit = (overrides: Partial<OrbitalElements> = {}): OrbitalElements => ({
|
||||||
semiMajorAxisAu: 1,
|
semiMajorAxisAu: 1,
|
||||||
@@ -34,6 +38,9 @@ const earth: BodyRecord = {
|
|||||||
kind: 'planet',
|
kind: 'planet',
|
||||||
radiusKm: 6371,
|
radiusKm: 6371,
|
||||||
orbit: orbit(),
|
orbit: orbit(),
|
||||||
|
// Standish's mean longitude rate, 35 999.373 degrees a century.
|
||||||
|
rates: { meanMotionDegPerDay: 35999.37306329 / 36525, longitudeOfAscendingNodeDegPerDay: 0, argumentOfPeriapsisDegPerDay: 0 },
|
||||||
|
orbitSource: 'JPL approximate mean elements (Standish), fit for 3000 BC to AD 3000',
|
||||||
};
|
};
|
||||||
const luna: BodyRecord = {
|
const luna: BodyRecord = {
|
||||||
id: 'luna',
|
id: 'luna',
|
||||||
@@ -43,45 +50,74 @@ const luna: BodyRecord = {
|
|||||||
radiusKm: 1737,
|
radiusKm: 1737,
|
||||||
parentBodyId: 'earth',
|
parentBodyId: 'earth',
|
||||||
orbit: orbit({ semiMajorAxisAu: 0.00257 }),
|
orbit: orbit({ semiMajorAxisAu: 0.00257 }),
|
||||||
|
// JPL SSD's sidereal mean motion for the Moon.
|
||||||
|
rates: { meanMotionDegPerDay: 13.176358, longitudeOfAscendingNodeDegPerDay: -0.05299, argumentOfPeriapsisDegPerDay: 0.16435 },
|
||||||
|
orbitSource: 'JPL SSD satellite mean elements, epoch 2000 Jan 1',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('heliocentricPeriodDays', () => {
|
|
||||||
it('recovers a known period from the semi-major axis alone', () => {
|
|
||||||
// P² = a³ in these units, so Earth must come back a year.
|
|
||||||
expect(heliocentricPeriodDays(earth)).toBeCloseTo(365.25, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('scales as the three-halves power', () => {
|
|
||||||
const jupiter: BodyRecord = {
|
|
||||||
...earth,
|
|
||||||
id: 'jupiter',
|
|
||||||
name: 'Jupiter',
|
|
||||||
orbit: orbit({ semiMajorAxisAu: 5.2044 }),
|
|
||||||
};
|
|
||||||
// Jupiter's real sidereal period is 4332.6 days.
|
|
||||||
expect(heliocentricPeriodDays(jupiter)).toBeCloseTo(4335, -1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses to compute a period for a moon', () => {
|
|
||||||
// A moon's elements are relative to its planet, whose mass is not in the catalogue — the
|
|
||||||
// same arithmetic would be wrong by the ratio of that planet's mass to the Sun's.
|
|
||||||
expect(heliocentricPeriodDays(luna)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('buildBodyViewModel', () => {
|
describe('buildBodyViewModel', () => {
|
||||||
const catalogues = { bodies: [earth, luna], exoplanets: [] as ExoplanetRecord[], stars: [sun] };
|
const catalogues = { bodies: [earth, luna], exoplanets: [] as ExoplanetRecord[], stars: [sun] };
|
||||||
|
|
||||||
it('marks a period computed from the semi-major axis as derived', () => {
|
it('gives a planet the sidereal year its published mean motion goes round in', () => {
|
||||||
const model = buildBodyViewModel('earth', catalogues);
|
const model = buildBodyViewModel('earth', catalogues);
|
||||||
expect(model?.orbitalPeriodSource).toBe('derived');
|
expect(model?.orbitalPeriodSource).toBe('measured');
|
||||||
expect(model?.orbitalPeriodDays).toBeCloseTo(365.25, 1);
|
expect(model?.orbitalPeriodDays).toBeCloseTo(365.2564, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves a moon without a period rather than inventing one', () => {
|
it('gives a moon its period too, from the same mean motion that carries it round', () => {
|
||||||
|
// The card used to refuse, while the scene turned the Moon round the Earth all the same.
|
||||||
const model = buildBodyViewModel('luna', catalogues);
|
const model = buildBodyViewModel('luna', catalogues);
|
||||||
expect(model?.orbitalPeriodDays).toBeUndefined();
|
expect(model?.orbitalPeriodSource).toBe('measured');
|
||||||
expect(model?.orbitalPeriodSource).toBeUndefined();
|
expect(model?.orbitalPeriodDays).toBeCloseTo(27.32166, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints the size of an inclination fitted below zero, as the same orbit with its node turned half round', () => {
|
||||||
|
const tilted: BodyRecord = { ...earth, orbit: orbit({ inclinationDeg: -0.00054346 }) };
|
||||||
|
const model = buildBodyViewModel('earth', { ...catalogues, bodies: [tilted] })!;
|
||||||
|
expect(bodyReadouts(model).measured.find((row) => row.label === 'Inclination')?.value).toBe('0.00°');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints the eccentricity measured for a moon whose orbit keeps an older one', () => {
|
||||||
|
const hyperion: BodyRecord = { ...luna, id: 'hyperion', orbit: orbit({ eccentricity: 0.0232 }), measuredEccentricity: 0.105 };
|
||||||
|
const model = buildBodyViewModel('hyperion', { ...catalogues, bodies: [earth, hyperion] })!;
|
||||||
|
expect(bodyReadouts(model).measured.find((row) => row.label === 'Eccentricity')?.value).toBe('0.105');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a triaxial body its semi-axes beside its mean radius, not a radius alone', () => {
|
||||||
|
// As shipped: Haumea's shape (Ortiz et al. 2017) travels from the ETL's spec to its card.
|
||||||
|
const shipped: BodyRecord[] = JSON.parse(readFileSync(`${process.cwd()}/src/assets/data/bodies.json`, 'utf8'));
|
||||||
|
const haumea = shipped.find((body) => body.id === 'haumea')!;
|
||||||
|
const measured = bodyReadouts(buildBodyViewModel('haumea', { ...catalogues, bodies: [earth, haumea] })!).measured;
|
||||||
|
expect(measured.find((row) => row.label === 'Mean radius')?.value).toBe('798 km');
|
||||||
|
expect(measured.find((row) => row.label === 'Semi-axes')?.value).toBe('1,161 × 852 × 513 km');
|
||||||
|
expect(measured.find((row) => row.label === 'Radius')).toBeUndefined();
|
||||||
|
// Every other body keeps its one radius.
|
||||||
|
expect(bodyReadouts(buildBodyViewModel('earth', catalogues)!).measured.find((row) => row.label === 'Radius')?.value).toBe('6,371 km');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says where the orbit comes from, in the card’s provenance', () => {
|
||||||
|
expect(bodyReadouts(buildBodyViewModel('luna', catalogues)!).provenance).toContain('Orbit: JPL SSD satellite mean elements, epoch 2000 Jan 1.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says a moon without a map is illustrated, without saying it was never imaged', () => {
|
||||||
|
// luna has no map under that id. Voyager and Cassini photographed every moon drawn this way.
|
||||||
|
const provenance = bodyReadouts(buildBodyViewModel('luna', catalogues)!).provenance;
|
||||||
|
expect(provenance).toContain('Not an observation — no global map of this world is used here.');
|
||||||
|
expect(provenance).not.toContain('no image of this world exists');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says an exoplanet the archive does not flag as imaged has no image', () => {
|
||||||
|
const exoplanet: ExoplanetRecord = { id: 'x', hostStarId: SUN_STAR_ID, hostStarName: 'Sol', name: 'X b', orbit: { semiMajorAxisAu: 0.05 } };
|
||||||
|
const model = buildBodyViewModel('x', { bodies: [], exoplanets: [exoplanet], stars: [sun] })!;
|
||||||
|
expect(bodyReadouts(model).provenance).toContain('Not an observation — no image of this world exists.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says a directly imaged exoplanet was seen as a point of light, not that no image of it exists', () => {
|
||||||
|
// HR 8799 b: photographed beside its star at Gemini and Keck (Marois et al. 2008).
|
||||||
|
const exoplanet: ExoplanetRecord = { id: 'HR 8799 b', hostStarId: SUN_STAR_ID, hostStarName: 'HR 8799', name: 'HR 8799 b', imaged: true, orbit: { semiMajorAxisAu: 68 } };
|
||||||
|
const provenance = bodyReadouts(buildBodyViewModel('HR 8799 b', { bodies: [], exoplanets: [exoplanet], stars: [sun] })!).provenance;
|
||||||
|
expect(provenance).toContain('Not an observation — it has been imaged only as a point of light beside its star, and no map of it is used here.');
|
||||||
|
expect(provenance).not.toContain('no image of this world exists');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks a published exoplanet period as measured, not derived', () => {
|
it('marks a published exoplanet period as measured, not derived', () => {
|
||||||
@@ -122,3 +158,77 @@ describe('buildBodyViewModel', () => {
|
|||||||
expect(buildBodyViewModel('earth', catalogues)?.hostStarId).toBe(SUN_STAR_ID);
|
expect(buildBodyViewModel('earth', catalogues)?.hostStarId).toBe(SUN_STAR_ID);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('luminosityOf', () => {
|
||||||
|
// KMT-2016-BLG-1107L as the ETL adds it from the archive: no V, no G, so the stand-in 15.
|
||||||
|
const lens: StarRecord = { id: 1070000536, name: 'KMT-2016-BLG-1107L', x: 6651, y: 0, z: 0, magnitude: 15, spectralType: 'Unknown', colorIndex: null, source: 'exoplanet-archive' };
|
||||||
|
const lensB: ExoplanetRecord = { id: 'lens-b', hostStarId: lens.id, hostStarName: lens.name, name: 'KMT-2016-BLG-1107L b', orbit: { semiMajorAxisAu: 0.342 } };
|
||||||
|
|
||||||
|
it('has none from a magnitude no survey measured, and gives its planets no temperature from it', () => {
|
||||||
|
expect(luminosityOf(lens)).toBeNull();
|
||||||
|
expect(buildBodyViewModel('lens-b', { bodies: [], exoplanets: [lensB], stars: [lens] })?.appearance.equilibriumTemperatureK).toBeNull();
|
||||||
|
// The same figure measured in V is a star, 37 L☉ at that distance.
|
||||||
|
expect(luminosityOf({ ...lens, magnitudeBand: 'V' })).toBeCloseTo(36.8, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is 1 for the Sun, whatever its magnitude is filed under', () => {
|
||||||
|
expect(luminosityOf(sun)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('starSurfaceOf', () => {
|
||||||
|
// Proxima Centauri as HYG describes it, and one of its planets' archive rows.
|
||||||
|
const proxima: StarRecord = { id: 70666, name: 'Proxima Centauri', x: 1.2959, y: 0, z: 0, magnitude: 11.01, magnitudeBand: 'V', spectralType: 'M5Ve', colorIndex: 1.807, colorSystem: 'B-V' };
|
||||||
|
const proximaB: ExoplanetRecord = { id: 'proxima-cen-b', hostStarId: 70666, hostStarName: 'Proxima Cen', name: 'Proxima Cen b', orbit: { semiMajorAxisAu: 0.0485 } };
|
||||||
|
|
||||||
|
it("is the Sun's own for the Sun, and not derived", () => {
|
||||||
|
expect(starSurfaceOf(sun, [])).toEqual({ radiusSolar: 1, radiusDerived: false, temperatureK: 5772, luminositySolar: 1, luminosityDerived: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes a host's radius, temperature and luminosity from the archive", () => {
|
||||||
|
const surface = starSurfaceOf(proxima, [{ ...proximaB, hostStarRadiusSolar: 0.141, hostStarTemperatureK: 2900, hostStarLuminositySolar: 0.00151 }]);
|
||||||
|
expect(surface).toEqual({ radiusSolar: 0.141, radiusDerived: false, temperatureK: 2900, luminositySolar: 0.00151, luminosityDerived: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warms a planet by the luminosity the archive gives its host, on its own page as in its system', () => {
|
||||||
|
// 8.9×10⁻⁴ L☉ from Proxima's V and B−V, which put b at 200 K; the archive's 1.51×10⁻³ at 228 K.
|
||||||
|
const b = { ...proximaB, hostStarLuminositySolar: 0.00151 };
|
||||||
|
const catalogues = { bodies: [], exoplanets: [b, { ...proximaB, id: 'proxima-cen-d', name: 'Proxima Cen d', orbit: { semiMajorAxisAu: 0.02881 } }], stars: [proxima] };
|
||||||
|
expect(buildBodyViewModel('proxima-cen-b', catalogues)?.appearance.equilibriumTemperatureK).toBeCloseTo(228, 0);
|
||||||
|
// d's own row gives none; its host's luminosity is still the archive's, from b's.
|
||||||
|
expect(buildBodyViewModel('proxima-cen-d', catalogues)?.appearance.equilibriumTemperatureK).toBeCloseTo(296, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the star field the temperature the disc is drawn at, where a host's planets give it different ones", () => {
|
||||||
|
// 193 hosts do: host 1070876212's three rows give 4 094, 4 094 and 3 640 K. The field is tinted
|
||||||
|
// from one map of every host and the disc from the host's own planets, so both must take the same row.
|
||||||
|
const rows = [
|
||||||
|
{ ...proximaB, id: 'd', hostStarTemperatureK: undefined },
|
||||||
|
{ ...proximaB, id: 'b', hostStarTemperatureK: 4094 },
|
||||||
|
{ ...proximaB, id: 'c', hostStarTemperatureK: 3640 },
|
||||||
|
];
|
||||||
|
const other = { ...proximaB, id: 'other', hostStarId: 1, hostStarTemperatureK: 5000 };
|
||||||
|
expect(starSurfaceOf(proxima, rows).temperatureK).toBe(4094);
|
||||||
|
expect(publishedTemperaturesK([...rows, other]).get(proxima.id)).toBe(starSurfaceOf(proxima, rows).temperatureK);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives both otherwise, and says the radius is derived', () => {
|
||||||
|
const surface = starSurfaceOf(proxima, [proximaB]);
|
||||||
|
expect(surface.radiusDerived).toBe(true);
|
||||||
|
expect(surface.luminosityDerived).toBe(true);
|
||||||
|
expect(surface.luminositySolar).toBeCloseTo(0.00088, 5);
|
||||||
|
// Its colour reads as an M5 dwarf: 3 068 K and 0.105 R☉, against 2 900 K and 0.154 R☉
|
||||||
|
// measured (Kervella et al. 2017). B−V barely changes along the late M dwarfs.
|
||||||
|
expect(surface.temperatureK).toBeCloseTo(3068, -1);
|
||||||
|
expect(surface.radiusSolar).toBeCloseTo(0.105, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no radius for a star with neither a colour nor a type', () => {
|
||||||
|
expect(starSurfaceOf({ ...proxima, colorIndex: null, spectralType: 'Unknown' }, []).radiusSolar).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has none from a magnitude no survey measured', () => {
|
||||||
|
// No band: the magnitude is the ETL's stand-in, and the luminosity from it means nothing.
|
||||||
|
expect(starSurfaceOf({ ...proxima, magnitudeBand: undefined }, []).radiusSolar).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
||||||
import { EARTH_RADIUS_KM } from '../../shared/astro/planet-appearance';
|
import { EARTH_RADIUS_KM } from '../../shared/astro/planet-appearance';
|
||||||
import { luminositySolar } from '../../shared/astro/stellar';
|
import { effectiveTemperatureK, luminositySolar, radiusFromLuminositySolar, SOLAR_EFFECTIVE_TEMPERATURE_K, StellarPhotometry } from '../../shared/astro/stellar';
|
||||||
import { bodyTexturePath } from '../../shared/rendering/texture-catalog';
|
import { bodyTexturePath } from '../../shared/rendering/texture-catalog';
|
||||||
import { BodyRecord } from '../../shared/models/body.model';
|
import { BodyRecord } from '../../shared/models/body.model';
|
||||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||||
@@ -16,17 +16,88 @@ export interface BodyCatalogues {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Bolometric luminosity of a star in solar units, from what the catalogue measured: apparent
|
* Bolometric luminosity of a star in solar units, from what the catalogue measured: apparent
|
||||||
* magnitude, parallax distance, and a bolometric correction read off the spectral type.
|
* magnitude in its band, parallax distance, and a bolometric correction read off the colour, or
|
||||||
|
* off the spectral type where there is no colour.
|
||||||
|
*
|
||||||
|
* None where no survey measured the star and its magnitude is the ETL's stand-in, which is all
|
||||||
|
* 309 stars without a band have. The 265 archive hosts among them came out at a median 33 L☉
|
||||||
|
* from it, KMT-2016-BLG-1107L, a 0.087 M☉ star, at 37, and OGLE-2005-BLG-390L b, published at
|
||||||
|
* about 50 K, read 388 K. The Sun is the unit, whatever its magnitude is filed under.
|
||||||
*/
|
*/
|
||||||
export function luminosityOf(star: StarRecord | undefined): number | null {
|
export function luminosityOf(star: StarRecord | undefined): number | null {
|
||||||
if (!star) {
|
return star && (star.magnitudeBand || star.id === SUN_STAR_ID) ? luminositySolar(photometryOf(star)) : null;
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
return luminositySolar({
|
function photometryOf(star: StarRecord): StellarPhotometry {
|
||||||
|
return {
|
||||||
magnitude: star.magnitude,
|
magnitude: star.magnitude,
|
||||||
distancePc: Math.hypot(star.x, star.y, star.z),
|
distancePc: Math.hypot(star.x, star.y, star.z),
|
||||||
spectralType: star.spectralType,
|
spectralType: star.spectralType,
|
||||||
});
|
magnitudeBand: star.magnitudeBand,
|
||||||
|
colorIndex: star.colorIndex,
|
||||||
|
colorSystem: star.colorSystem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How big, how hot and how bright a star is, and whether each was measured or derived here. */
|
||||||
|
export interface StarSurface {
|
||||||
|
/** Solar radii; `null` without a published radius, a measured magnitude, and a colour or type. */
|
||||||
|
radiusSolar: number | null;
|
||||||
|
radiusDerived: boolean;
|
||||||
|
temperatureK: number | null;
|
||||||
|
/** Solar luminosities, what its planets are warmed by; `null` where there is neither. */
|
||||||
|
luminositySolar: number | null;
|
||||||
|
luminosityDerived: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The temperature each host's disc is drawn at where the archive gives one, by star id: the first
|
||||||
|
* among its planets, in their order, as {@link starSurfaceOf} takes it — for the star field, which
|
||||||
|
* tints every star the colour of its own disc.
|
||||||
|
*/
|
||||||
|
export function publishedTemperaturesK(exoplanets: readonly ExoplanetRecord[]): Map<number, number> {
|
||||||
|
const temperatures = new Map<number, number>();
|
||||||
|
for (const { hostStarId, hostStarTemperatureK } of exoplanets) {
|
||||||
|
if (hostStarId !== null && hostStarTemperatureK && !temperatures.has(hostStarId)) {
|
||||||
|
temperatures.set(hostStarId, hostStarTemperatureK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return temperatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A star's radius, effective temperature and luminosity: the archive's `st_rad`, `st_teff` and
|
||||||
|
* `st_lum` for a planet host, from any of its planets' rows, and otherwise derived — the
|
||||||
|
* temperature off the dwarf sequence at the star's colour, the luminosity from its magnitude
|
||||||
|
* (`luminosityOf`), the radius from those two (Stefan-Boltzmann). The Sun's are its own, the
|
||||||
|
* nominal values the rest are measured in.
|
||||||
|
*
|
||||||
|
* Derived radii land within a factor of 1.5 of the archive's for 97 % of the 1 447 catalogue
|
||||||
|
* hosts that have both, and within 0.018 dex at the median. Derived luminosities fare worse: 757
|
||||||
|
* of the 4 440 hosts the archive gives one for were off by more than that factor, 667 of them
|
||||||
|
* stars placed from the archive's own V and B−V, and Proxima read 8.9×10⁻⁴ L☉ against the
|
||||||
|
* archive's 1.51×10⁻³ beside the radius and temperature it was drawn with, which imply 1.27×10⁻³.
|
||||||
|
*/
|
||||||
|
export function starSurfaceOf(star: StarRecord, planets: readonly ExoplanetRecord[]): StarSurface {
|
||||||
|
if (star.id === SUN_STAR_ID) {
|
||||||
|
return { radiusSolar: 1, radiusDerived: false, temperatureK: SOLAR_EFFECTIVE_TEMPERATURE_K, luminositySolar: 1, luminosityDerived: false };
|
||||||
|
}
|
||||||
|
const temperatureK = planets.find((planet) => planet.hostStarTemperatureK)?.hostStarTemperatureK ?? effectiveTemperatureK(photometryOf(star));
|
||||||
|
const published = planets.find((planet) => planet.hostStarLuminositySolar)?.hostStarLuminositySolar;
|
||||||
|
// None from a stand-in magnitude: PSR J1719-1438 came out 2.3 solar radii, wider than its
|
||||||
|
// planet's orbit.
|
||||||
|
const derived = luminosityOf(star);
|
||||||
|
const luminosity = { luminositySolar: published ?? derived, luminosityDerived: published === undefined };
|
||||||
|
const measured = planets.find((planet) => planet.hostStarRadiusSolar)?.hostStarRadiusSolar;
|
||||||
|
if (measured) {
|
||||||
|
return { radiusSolar: measured, radiusDerived: false, temperatureK, ...luminosity };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
radiusSolar: derived !== null && temperatureK !== null ? radiusFromLuminositySolar(derived, temperatureK) : null,
|
||||||
|
radiusDerived: true,
|
||||||
|
temperatureK,
|
||||||
|
...luminosity,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,7 +112,10 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body
|
|||||||
const body = catalogues.bodies.find((candidate) => candidate.id === id);
|
const body = catalogues.bodies.find((candidate) => candidate.id === id);
|
||||||
if (body) {
|
if (body) {
|
||||||
const hostStar = catalogues.stars.find((star) => star.id === body.systemStarId);
|
const hostStar = catalogues.stars.find((star) => star.id === body.systemStarId);
|
||||||
const periodDays = heliocentricPeriodDays(body);
|
// The period the map draws, moons included: JPL's own mean motion, which is also what
|
||||||
|
// carries the body round the scene. Europa's card had no period at all while the scene
|
||||||
|
// turned it round Jupiter in 3.55 days.
|
||||||
|
const periodDays = 360 / body.rates.meanMotionDegPerDay;
|
||||||
return {
|
return {
|
||||||
id: body.id,
|
id: body.id,
|
||||||
name: body.name,
|
name: body.name,
|
||||||
@@ -49,11 +123,13 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body
|
|||||||
hostStarName: hostStar?.name ?? 'Unknown star',
|
hostStarName: hostStar?.name ?? 'Unknown star',
|
||||||
hostStarId: body.systemStarId,
|
hostStarId: body.systemStarId,
|
||||||
radiusKm: body.radiusKm,
|
radiusKm: body.radiusKm,
|
||||||
orbit: body.orbit,
|
semiAxesKm: body.semiAxesKm,
|
||||||
|
orbit: body.measuredEccentricity === undefined ? body.orbit : { ...body.orbit, eccentricity: body.measuredEccentricity },
|
||||||
appearance: appearanceForBody(body, catalogues.bodies, luminosityOf(hostStar)),
|
appearance: appearanceForBody(body, catalogues.bodies, luminosityOf(hostStar)),
|
||||||
hasPhotography: bodyTexturePath(body.id) !== undefined,
|
hasPhotography: bodyTexturePath(body.id) !== undefined,
|
||||||
orbitalPeriodDays: periodDays,
|
orbitalPeriodDays: periodDays,
|
||||||
orbitalPeriodSource: periodDays === undefined ? undefined : 'derived',
|
orbitalPeriodSource: 'measured',
|
||||||
|
orbitSource: body.orbitSource,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,8 +148,13 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body
|
|||||||
massEarth: exoplanet.massEarth,
|
massEarth: exoplanet.massEarth,
|
||||||
discoveryYear: exoplanet.discoveryYear,
|
discoveryYear: exoplanet.discoveryYear,
|
||||||
orbit: exoplanet.orbit,
|
orbit: exoplanet.orbit,
|
||||||
appearance: appearanceForExoplanet(exoplanet, luminosityOf(hostStar)),
|
// Warmed by what the system view warms it by: the archive's luminosity where it has one.
|
||||||
|
appearance: appearanceForExoplanet(
|
||||||
|
exoplanet,
|
||||||
|
hostStar ? starSurfaceOf(hostStar, catalogues.exoplanets.filter((candidate) => candidate.hostStarId === hostStar.id)).luminositySolar : null,
|
||||||
|
),
|
||||||
hasPhotography: bodyTexturePath(exoplanet.id) !== undefined,
|
hasPhotography: bodyTexturePath(exoplanet.id) !== undefined,
|
||||||
|
imaged: exoplanet.imaged,
|
||||||
// `periodDays` is populated for none of the shipped records, and deriving one would need the
|
// `periodDays` is populated for none of the shipped records, and deriving one would need the
|
||||||
// host star's mass, which is equally absent. Left undefined rather than assuming a solar-mass
|
// host star's mass, which is equally absent. Left undefined rather than assuming a solar-mass
|
||||||
// host, which would silently mis-state the period of every planet around an M dwarf.
|
// host, which would silently mis-state the period of every planet around an M dwarf.
|
||||||
@@ -81,19 +162,3 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body
|
|||||||
orbitalPeriodSource: exoplanet.periodDays === undefined ? undefined : 'measured',
|
orbitalPeriodSource: exoplanet.periodDays === undefined ? undefined : 'measured',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Kepler's third law for a body orbiting the Sun: P² = a³ with P in years and a in AU, which
|
|
||||||
* holds exactly in these units because the Sun's mass is the unit of mass.
|
|
||||||
*
|
|
||||||
* Only for heliocentric orbits. A moon's elements are relative to its parent planet, whose mass
|
|
||||||
* the catalogue does not carry, so the same arithmetic there would be wrong by the ratio of the
|
|
||||||
* planet's mass to the Sun's — a factor of a thousand for Jupiter.
|
|
||||||
*/
|
|
||||||
export function heliocentricPeriodDays(body: BodyRecord): number | undefined {
|
|
||||||
if (body.parentBodyId !== undefined || body.systemStarId !== SUN_STAR_ID) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const a = body.orbit.semiMajorAxisAu;
|
|
||||||
return a > 0 ? Math.pow(a, 1.5) * 365.25 : undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { ArticleService } from '../../core/data/article.service';
|
||||||
|
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
|
||||||
|
import { BodyDetailViewModel } from './body-detail.model';
|
||||||
|
import { InfoPanelComponent } from './info-panel.component';
|
||||||
|
|
||||||
|
const planet: BodyDetailViewModel = {
|
||||||
|
id: '2MASS J21252752-8138278 b',
|
||||||
|
name: '2MASS J21252752-8138278 b',
|
||||||
|
kind: 'exoplanet',
|
||||||
|
hostStarName: '2MASS J21252752-8138278',
|
||||||
|
orbit: { semiMajorAxisAu: 7493 },
|
||||||
|
appearance: { planetClass: 'gasGiant', palette: { structure: 'banded' }, equilibriumTemperatureK: 2.74, bulkDensityGramsPerCm3: null, polarCapExtentDeg: 0, seed: 1 } as unknown as PlanetAppearance,
|
||||||
|
hasPhotography: false
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('InfoPanelComponent', () => {
|
||||||
|
let fixture: ComponentFixture<InfoPanelComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [InfoPanelComponent],
|
||||||
|
providers: [provideRouter([]), { provide: ArticleService, useValue: { lookup: async () => ({ status: 'none' }) } }]
|
||||||
|
}).compileComponents();
|
||||||
|
fixture = TestBed.createComponent(InfoPanelComponent);
|
||||||
|
fixture.componentRef.setInput('body', planet);
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps a long designation rather than cutting off the digits that tell it apart', () => {
|
||||||
|
const host = fixture.nativeElement as HTMLElement;
|
||||||
|
const heading = host.querySelector('h1')!;
|
||||||
|
const eyebrow = heading.nextElementSibling!;
|
||||||
|
expect(heading.textContent?.trim()).toBe('2MASS J21252752-8138278 b');
|
||||||
|
for (const line of [heading, eyebrow]) {
|
||||||
|
expect(line.classList).not.toContain('truncate');
|
||||||
|
expect(line.classList).toContain('wrap-break-word');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,8 +35,10 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
|
|||||||
|
|
||||||
<header class="flex items-start gap-2 px-4 pt-4 pb-3">
|
<header class="flex items-start gap-2 px-4 pt-4 pb-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<h1 class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</h1>
|
<!-- Wrapped, not truncated: a designation's last digits are the ones that tell it from its
|
||||||
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
neighbours, and an ellipsis took exactly those off "2MASS J21252752-8138278 b". -->
|
||||||
|
<h1 class="text-lg leading-tight font-bold tracking-[0.04em] wrap-break-word text-text uppercase">{{ body().name }}</h1>
|
||||||
|
<p class="type-eyebrow mt-1 wrap-break-word text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -6,16 +6,27 @@ import { afterEach, beforeEach, describe, expect, it, MockInstance, vi } from 'v
|
|||||||
import { DataLoaderService, StarField } from '../../core/data/data-loader.service';
|
import { DataLoaderService, StarField } from '../../core/data/data-loader.service';
|
||||||
import { EngineService, EngineTickCallback } from '../../core/engine/engine.service';
|
import { EngineService, EngineTickCallback } from '../../core/engine/engine.service';
|
||||||
import { BodyRecord } from '../../shared/models/body.model';
|
import { BodyRecord } from '../../shared/models/body.model';
|
||||||
|
import { GM_SUN_AU3_PER_DAY2 } from '../../shared/astro/constants';
|
||||||
|
import { keplerRates } from '../../shared/astro/kepler';
|
||||||
import { DeepSkyRecord } from '../../shared/models/deepsky.model';
|
import { DeepSkyRecord } from '../../shared/models/deepsky.model';
|
||||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||||
import { StarRecord } from '../../shared/models/star.model';
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||||
|
import { TimeStore } from '../../shared/state/time.store';
|
||||||
import { LinkBudget } from '../../shared/astro/jump-links';
|
import { LinkBudget } from '../../shared/astro/jump-links';
|
||||||
import { HudDisplay } from '../hud/hud-dock.component';
|
import { HudDisplay } from '../hud/hud-dock.component';
|
||||||
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
|
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
|
||||||
|
import { normalView, positionViewDirection } from 'three/tsl';
|
||||||
import { galacticNormal } from './grid-plane';
|
import { galacticNormal } from './grid-plane';
|
||||||
|
import { catalogueCensus, positionsNote } from './star-readouts';
|
||||||
|
import { closestApproachAu, SUN_RADIUS_AU, systemFramingDistanceAu } from './system-framing';
|
||||||
|
import { blackbodyColor, SOLAR_EFFECTIVE_TEMPERATURE_K } from '../../shared/astro/stellar';
|
||||||
|
import { appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
||||||
|
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
||||||
|
import { loadCachedTexture, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
|
||||||
import { JumpLinkRenderer } from './jump-link-renderer';
|
import { JumpLinkRenderer } from './jump-link-renderer';
|
||||||
import { StarFieldRenderer } from './star-field-renderer';
|
import { StarFieldRenderer } from './star-field-renderer';
|
||||||
|
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
||||||
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
|
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
|
||||||
|
|
||||||
// jsdom does not implement ResizeObserver; the component only uses it to react to real
|
// jsdom does not implement ResizeObserver; the component only uses it to react to real
|
||||||
@@ -31,9 +42,32 @@ const ALPHA_CENTAURI: StarRecord = { id: 1, name: 'Alpha Centauri', x: 1.34, y:
|
|||||||
// Its id deliberately differs from its place in STARS, so a lookup by id cannot pass for one by index.
|
// Its id deliberately differs from its place in STARS, so a lookup by id cannot pass for one by index.
|
||||||
const PROXIMA: StarRecord = { id: 42, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 };
|
const PROXIMA: StarRecord = { id: 42, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 };
|
||||||
|
|
||||||
const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA];
|
// A supergiant nothing publishes a radius for, and a white dwarf with neither a colour nor a type
|
||||||
|
// the parser reads, for what the system view draws each star at; last, so the indices above hold.
|
||||||
|
const ANTARES: StarRecord = { id: 80519, name: 'Antares', x: -58.54, y: -140.31, z: -75.57, magnitude: 1.06, magnitudeBand: 'V', spectralType: 'M1Ib + B2.5V', colorIndex: 1.865, colorSystem: 'B-V' };
|
||||||
|
const PROCYON_B: StarRecord = { id: 37279, name: 'Gl 280B', x: -1.08, y: 3.19, z: 0.34, magnitude: 10.7, magnitudeBand: 'V', spectralType: 'DA', colorIndex: null };
|
||||||
|
|
||||||
|
// A host only the archive places, at the stand-in magnitude, with no type and no colour.
|
||||||
|
const LENS: StarRecord = { id: 1070000536, name: 'KMT-2016-BLG-1107L', x: 6651, y: 0, z: 0, magnitude: 15, spectralType: 'Unknown', colorIndex: null, source: 'exoplanet-archive' };
|
||||||
|
|
||||||
|
const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA, ANTARES, PROCYON_B, LENS];
|
||||||
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
|
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
|
||||||
|
|
||||||
|
// Proxima's planet as the archive gives it, with its host's radius, temperature and luminosity.
|
||||||
|
const PROXIMA_B: ExoplanetRecord = {
|
||||||
|
id: 'Proxima Cen b',
|
||||||
|
hostStarId: PROXIMA.id,
|
||||||
|
hostStarName: 'Proxima Cen',
|
||||||
|
name: 'Proxima Cen b',
|
||||||
|
hostStarRadiusSolar: 0.141,
|
||||||
|
hostStarTemperatureK: 2900,
|
||||||
|
hostStarLuminositySolar: 0.00151,
|
||||||
|
orbit: { semiMajorAxisAu: 0.0485, eccentricity: 0.02 }
|
||||||
|
};
|
||||||
|
|
||||||
|
// A microlensing planet, whose host the archive places 6.7 kpc out.
|
||||||
|
const LENS_B: ExoplanetRecord = { id: 'KMT-2016-BLG-1107L b', hostStarId: LENS.id, hostStarName: 'KMT-2016-BLG-1107L', name: 'KMT-2016-BLG-1107L b', orbit: {} };
|
||||||
|
|
||||||
const DEEP_SKY_OBJECT: DeepSkyRecord = {
|
const DEEP_SKY_OBJECT: DeepSkyRecord = {
|
||||||
id: 'NGC0224',
|
id: 'NGC0224',
|
||||||
name: 'Andromeda Galaxy',
|
name: 'Andromeda Galaxy',
|
||||||
@@ -63,7 +97,8 @@ const EARTH: BodyRecord = {
|
|||||||
argumentOfPeriapsisDeg: 0,
|
argumentOfPeriapsisDeg: 0,
|
||||||
meanAnomalyAtEpochDeg: 0,
|
meanAnomalyAtEpochDeg: 0,
|
||||||
epochJd: 2451545.0
|
epochJd: 2451545.0
|
||||||
}
|
},
|
||||||
|
rates: keplerRates(1, GM_SUN_AU3_PER_DAY2), orbitSource: 'test'
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Minimal stand-in for `EngineService` that skips real WebGPU/WebGL initialization entirely,
|
/** Minimal stand-in for `EngineService` that skips real WebGPU/WebGL initialization entirely,
|
||||||
@@ -160,7 +195,7 @@ class FakeDataLoaderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadExoplanets(): Promise<ExoplanetRecord[]> {
|
loadExoplanets(): Promise<ExoplanetRecord[]> {
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([PROXIMA_B, LENS_B]);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDeepSky(): Promise<DeepSkyRecord[]> {
|
loadDeepSky(): Promise<DeepSkyRecord[]> {
|
||||||
@@ -211,6 +246,12 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
|||||||
await flushAsync();
|
await flushAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rings the systems inside the survey edge, and puts them first in the draw budget, not a host kiloparsecs out', () => {
|
||||||
|
const scene = fixture.componentInstance as unknown as { hostRings: { count: number }; hostStars: Uint8Array };
|
||||||
|
expect(scene.hostRings.count).toBe(2);
|
||||||
|
expect([...scene.hostStars]).toEqual(STARS.map((star) => (star === SUN || star === PROXIMA ? 1 : 0)));
|
||||||
|
});
|
||||||
|
|
||||||
it('starts in the galaxy view with the system group hidden', () => {
|
it('starts in the galaxy view with the system group hidden', () => {
|
||||||
const component = fixture.componentInstance as unknown as { galaxyGroup: THREE.Group; systemGroup: THREE.Group };
|
const component = fixture.componentInstance as unknown as { galaxyGroup: THREE.Group; systemGroup: THREE.Group };
|
||||||
expect(component.galaxyGroup.visible).toBe(true);
|
expect(component.galaxyGroup.visible).toBe(true);
|
||||||
@@ -270,8 +311,9 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
|||||||
expect(refocus).toHaveBeenCalledTimes(1);
|
expect(refocus).toHaveBeenCalledTimes(1);
|
||||||
const [focus] = refocus.mock.calls[0];
|
const [focus] = refocus.mock.calls[0];
|
||||||
expect(focus.view).toBeDefined();
|
expect(focus.view).toBeDefined();
|
||||||
// The Sun has Earth, so it is a host; the others have nothing catalogued.
|
// The Sun has Earth and Proxima its b, so both are hosts; the lens has a planet too, but 6.7 kpc
|
||||||
expect(Array.from(focus.hosts ?? [])).toEqual([1, 0, 0]);
|
// out, past the survey edge; the others have nothing catalogued.
|
||||||
|
expect(Array.from(focus.hosts ?? [])).toEqual([1, 0, 1, 0, 0, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('chooses again once the camera has turned half the margin, and not for less', async () => {
|
it('chooses again once the camera has turned half the margin, and not for less', async () => {
|
||||||
@@ -828,6 +870,111 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
|||||||
expect(navigationStore.viewLevel()).toBe('system');
|
expect(navigationStore.viewLevel()).toBe('system');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('says where a system’s orbits come from, and for the Sun how long they hold', async () => {
|
||||||
|
const note = (): string => (fixture.componentInstance as unknown as { hudNote: () => string }).hudNote();
|
||||||
|
navigationStore.selectStar(SUN.id);
|
||||||
|
await flushAsync();
|
||||||
|
await advanceFrames(engine, 2.5);
|
||||||
|
expect(note()).toMatch(/^Orbits propagated from JPL mean elements, the planets’ fit for 3000 BC to AD 3000 and the moons’ checked from 1950 to 2100, and the SBDB’s osculating ones for Ceres, Eris, Haumea and Makemake, checked over the same span, to now, \d{4}-\d\d-\d\d \d\d:\d\d UTC\.$/);
|
||||||
|
|
||||||
|
navigationStore.selectStar(ALPHA_CENTAURI.id);
|
||||||
|
await flushAsync();
|
||||||
|
await advanceFrames(engine, 5);
|
||||||
|
expect(note()).toMatch(/^Orbits propagated from published elements to now, \d{4}-\d\d-\d\d \d\d:\d\d UTC\.$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns the Sun about its IAU pole, once in 25.38 days', async () => {
|
||||||
|
const time = TestBed.inject(TimeStore);
|
||||||
|
time.setRate(0);
|
||||||
|
time.setDate(new Date('2026-01-01T00:00Z'));
|
||||||
|
navigationStore.selectStar(SUN.id);
|
||||||
|
await flushAsync();
|
||||||
|
await advanceFrames(engine, 2.5);
|
||||||
|
const sun = (): THREE.Object3D => (fixture.componentInstance as unknown as { starMarker: THREE.Object3D }).starMarker;
|
||||||
|
const turned = (local: THREE.Vector3): THREE.Vector3 => local.applyQuaternion(sun().getWorldQuaternion(new THREE.Quaternion()));
|
||||||
|
|
||||||
|
// The sphere's +Y, which MAP_TO_BODY carries onto the body's pole, at RA 286.13, Dec 63.87.
|
||||||
|
const ra = (286.13 * Math.PI) / 180;
|
||||||
|
const dec = (63.87 * Math.PI) / 180;
|
||||||
|
const pole = new THREE.Vector3(Math.cos(dec) * Math.cos(ra), Math.cos(dec) * Math.sin(ra), Math.sin(dec));
|
||||||
|
expect(turned(new THREE.Vector3(0, 1, 0)).angleTo(pole)).toBeLessThan(1e-6);
|
||||||
|
|
||||||
|
const before = turned(new THREE.Vector3(1, 0, 0));
|
||||||
|
time.setDate(new Date('2026-01-02T00:00Z'));
|
||||||
|
await advanceFrames(engine, 0.1);
|
||||||
|
expect((turned(new THREE.Vector3(1, 0, 0)).angleTo(before) * 180) / Math.PI).toBeCloseTo(14.1844, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the date the system is drawn for once the clock is set to one', async () => {
|
||||||
|
const note = (): string => (fixture.componentInstance as unknown as { hudNote: () => string }).hudNote();
|
||||||
|
TestBed.inject(TimeStore).setDate(new Date('2020-12-21T18:00Z'));
|
||||||
|
navigationStore.selectStar(SUN.id);
|
||||||
|
await flushAsync();
|
||||||
|
await advanceFrames(engine, 2.5);
|
||||||
|
// The great conjunction, to the minute: not "now", and not a date the reader has to find.
|
||||||
|
expect(note()).toMatch(/ to 2020-12-21 18:00 UTC\.$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with Eris, whose aphelion runs past the grid', () => {
|
||||||
|
type FramedScene = { bodies: BodyRecord[]; controls: { target: THREE.Vector3 }; systemRenderer: SystemOrbitsRenderer; systemGroup: THREE.Group };
|
||||||
|
// Its 67.9 AU axis gives the grid an 80 AU outer ring; at aphelion it is 97.7 AU out.
|
||||||
|
const ERIS: BodyRecord = {
|
||||||
|
...EARTH, id: 'eris', name: 'Eris', kind: 'dwarf', radiusKm: 1163,
|
||||||
|
orbit: { ...EARTH.orbit, semiMajorAxisAu: 67.934, eccentricity: 0.4382 }, rates: keplerRates(67.934, GM_SUN_AU3_PER_DAY2)
|
||||||
|
};
|
||||||
|
|
||||||
|
async function enterTheSun(aspect: number): Promise<FramedScene> {
|
||||||
|
const component = fixture.componentInstance as unknown as FramedScene;
|
||||||
|
component.bodies = [EARTH, ERIS];
|
||||||
|
engine.getPerspectiveCamera().aspect = aspect;
|
||||||
|
navigationStore.selectStar(SUN.id);
|
||||||
|
await flushAsync();
|
||||||
|
await advanceFrames(engine, 2.5);
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('frames the furthest the system draws, Eris’s aphelion, not the ring inside it nor its semi-major axis', async () => {
|
||||||
|
const component = await enterTheSun(1);
|
||||||
|
const camera = engine.getPerspectiveCamera();
|
||||||
|
expect(component.systemRenderer.outermostRadiusAu).toBeCloseTo(67.934 * 1.4382, 9);
|
||||||
|
// 234.7 AU; framed on the 67.9 AU axis the camera would stand at 163 AU and Eris arrive off screen.
|
||||||
|
expect(camera.position.distanceTo(component.controls.target)).toBeCloseTo(
|
||||||
|
systemFramingDistanceAu(component.systemRenderer.outermostRadiusAu, { fovDegrees: camera.fov, aspect: camera.aspect }),
|
||||||
|
6
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds Earth to its 3-pixel floor at the arrival framing, where its true radius is far under a pixel', async () => {
|
||||||
|
Object.defineProperty((fixture.nativeElement as HTMLElement).querySelector('canvas')!, 'clientHeight', { value: 1000 });
|
||||||
|
const component = await enterTheSun(1.6);
|
||||||
|
const earth = component.systemRenderer.members.find((member) => member.id === 'earth')!.marker as THREE.Mesh;
|
||||||
|
const pixelAu = (2 * engine.visibleHalfHeight(engine.getCamera().position.distanceTo(earth.getWorldPosition(new THREE.Vector3())))) / 1000;
|
||||||
|
expect((earth.scale.x * (earth.geometry as THREE.SphereGeometry).parameters.radius) / pixelAu).toBeCloseTo(3, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the system outwards even from a phone’s framing, which stands past the 400 AU it used to fly to', async () => {
|
||||||
|
const component = await enterTheSun(390 / 844);
|
||||||
|
const camera = engine.getCamera();
|
||||||
|
const arrival = camera.position.length();
|
||||||
|
expect(arrival).toBeGreaterThan(500);
|
||||||
|
|
||||||
|
navigationStore.selectStar(null);
|
||||||
|
await flushAsync(1);
|
||||||
|
// Until the swap: it flew 508 AU in to 400, and the system grew on screen while the reader left it.
|
||||||
|
let previous = arrival;
|
||||||
|
for (let frame = 0; frame < 100 && component.systemGroup.visible; frame++) {
|
||||||
|
engine.tick(0.05);
|
||||||
|
await flushAsync(1);
|
||||||
|
if (component.systemGroup.visible) {
|
||||||
|
expect(camera.position.length()).toBeGreaterThanOrEqual(previous - 1e-9);
|
||||||
|
previous = camera.position.length();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(component.systemGroup.visible).toBe(false);
|
||||||
|
expect(previous).toBeGreaterThan(arrival);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('performs the floating-origin recenter: the camera lands close to the AU-space origin, not out at parsec-scale coordinates', async () => {
|
it('performs the floating-origin recenter: the camera lands close to the AU-space origin, not out at parsec-scale coordinates', async () => {
|
||||||
navigationStore.selectStar(ALPHA_CENTAURI.id);
|
navigationStore.selectStar(ALPHA_CENTAURI.id);
|
||||||
await flushAsync();
|
await flushAsync();
|
||||||
@@ -932,6 +1079,189 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
|||||||
expect(navigationStore.viewLevel()).toBe('galactic');
|
expect(navigationStore.viewLevel()).toBe('galactic');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('names what the neighbourhood holds, and where the positions of the stars in it come from', async () => {
|
||||||
|
await advanceFrames(engine, 0.3);
|
||||||
|
const component = fixture.componentInstance as unknown as { hudSubtitle(): string; hudNote(): string };
|
||||||
|
expect(component.hudSubtitle()).toBe(catalogueCensus(STARS));
|
||||||
|
expect(component.hudNote()).toBe(positionsNote(STARS));
|
||||||
|
expect(component.hudNote()).toContain('1 planet hosts only the NASA Exoplanet Archive places, from its distances');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers a star for a route by its classification, and a star with none by its name alone', () => {
|
||||||
|
const component = fixture.componentInstance as unknown as {
|
||||||
|
starSearchIndex(): { entry: { starId?: number; subtitle: string } }[];
|
||||||
|
routeOptions(): { id: number; subtitle: string }[];
|
||||||
|
onRouteQuery(query: string): void;
|
||||||
|
currentStarOption(): { subtitle: string } | null;
|
||||||
|
};
|
||||||
|
const optionFor = (query: string, id: number): string | undefined => {
|
||||||
|
component.onRouteQuery(query);
|
||||||
|
return component.routeOptions().find((option) => option.id === id)?.subtitle;
|
||||||
|
};
|
||||||
|
expect(optionFor('KMT-2016', LENS.id)).toBe('');
|
||||||
|
expect(optionFor('Proxima', PROXIMA.id)).toBe('M5V');
|
||||||
|
// Classified for the options shown only: the index holds none, where all 455 571 stars cost 140-230 ms at boot.
|
||||||
|
expect(component.starSearchIndex().every(({ entry }) => entry.subtitle === '')).toBe(true);
|
||||||
|
navigationStore.selectStar(LENS.id);
|
||||||
|
expect(component.currentStarOption()?.subtitle).toBe('');
|
||||||
|
navigationStore.selectStar(PROXIMA.id);
|
||||||
|
expect(component.currentStarOption()?.subtitle).toBe('M5V');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('each star at its own size and in its own colour', () => {
|
||||||
|
type DrawnStar = {
|
||||||
|
starMarkerGeometry: THREE.SphereGeometry;
|
||||||
|
starTint: { value: THREE.Color };
|
||||||
|
controls: { minDistance: number };
|
||||||
|
systemRenderer: { object: THREE.Object3D };
|
||||||
|
hudReadouts(): { label: string; value: string; derived?: boolean }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
async function enter(star: StarRecord): Promise<DrawnStar> {
|
||||||
|
navigationStore.selectStar(star.id);
|
||||||
|
await flushAsync();
|
||||||
|
await advanceFrames(engine, 2.5);
|
||||||
|
return fixture.componentInstance as unknown as DrawnStar;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectColour(colour: THREE.Color, [red, green, blue]: readonly number[]): void {
|
||||||
|
expect([colour.r, colour.g, colour.b].map((channel) => channel.toFixed(4))).toEqual([red, green, blue].map((channel) => channel.toFixed(4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('draws a host at the radius and in the colour the archive gives it, lights its planets in its light, and says how bright it is', async () => {
|
||||||
|
const scene = await enter(PROXIMA);
|
||||||
|
expect(scene.starMarkerGeometry.parameters.radius).toBeCloseTo(0.141 * SUN_RADIUS_AU, 12);
|
||||||
|
expectColour(scene.starTint.value, blackbodyColor(2900));
|
||||||
|
const light = scene.systemRenderer.object.children.find((child): child is THREE.PointLight => child instanceof THREE.PointLight)!;
|
||||||
|
expectColour(light.color, blackbodyColor(2900, SOLAR_EFFECTIVE_TEMPERATURE_K));
|
||||||
|
expect(scene.hudReadouts().find((readout) => readout.label === 'Luminosity')).toEqual({ label: 'Luminosity', value: '0.0015 L☉' });
|
||||||
|
expect(scene.hudReadouts().find((readout) => readout.label === 'Radius')?.value).toBe('0.141 solar radii');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws the disc as the Sun's photograph in the star's colour, darkened towards the limb", async () => {
|
||||||
|
// The disc's parts, read off the material's node graph: the tint the star's temperature sets
|
||||||
|
// and the photograph, times the limb factor, which is worked out below at a few angles.
|
||||||
|
// Checking only that 0.6 was somewhere in the graph passed a limb brighter than the centre.
|
||||||
|
const scene = await enter(PROXIMA);
|
||||||
|
const colorNode = (scene as unknown as { starMarkerMaterial: THREE.MeshBasicNodeMaterial }).starMarkerMaterial.colorNode!;
|
||||||
|
const nodes = new Set<THREE.Node>();
|
||||||
|
const walk = (node: THREE.Node): void => {
|
||||||
|
if (!nodes.has(node)) {
|
||||||
|
nodes.add(node);
|
||||||
|
for (const child of node.getChildren()) {
|
||||||
|
walk(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(colorNode);
|
||||||
|
const values = [...nodes].map((node) => (node as { value?: unknown }).value);
|
||||||
|
expect(values).toContain(scene.starTint.value);
|
||||||
|
expect(values).toContain(loadCachedTexture(SUN_TEXTURE_PATH));
|
||||||
|
|
||||||
|
// The factor the photograph is multiplied by, as the shader computes it where the cosine
|
||||||
|
// between the surface normal and the line of sight is `cosine`. Only the operations a limb
|
||||||
|
// law is written with are known; anything else fails rather than being guessed at.
|
||||||
|
type Graph = { isConstNode?: boolean; isVarNode?: boolean; value?: number; op?: string; method?: string; node: Graph; aNode: Graph; bNode: Graph; cNode: Graph };
|
||||||
|
const factorAt = (node: Graph, cosine: number): number => {
|
||||||
|
const at = (child: Graph): number => factorAt(child, cosine);
|
||||||
|
if (node.isVarNode) {
|
||||||
|
return at(node.node);
|
||||||
|
}
|
||||||
|
if (node.isConstNode) {
|
||||||
|
return node.value!;
|
||||||
|
}
|
||||||
|
switch (node.op ?? node.method) {
|
||||||
|
case '+': return at(node.aNode) + at(node.bNode);
|
||||||
|
case '-': return at(node.aNode) - at(node.bNode);
|
||||||
|
case '*': return at(node.aNode) * at(node.bNode);
|
||||||
|
case 'oneMinus': return 1 - at(node.aNode);
|
||||||
|
case 'negate': return -at(node.aNode);
|
||||||
|
case 'clamp': return Math.min(at(node.cNode), Math.max(at(node.bNode), at(node.aNode)));
|
||||||
|
case 'dot':
|
||||||
|
expect(node.aNode).toBe(normalView);
|
||||||
|
expect(node.bNode).toBe(positionViewDirection);
|
||||||
|
return cosine;
|
||||||
|
default: throw new Error(`the limb factor has an operation the test cannot work out: ${node.op ?? node.method}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// The graph is (photograph × tint) × factor, each step held in a variable.
|
||||||
|
const factor = (colorNode as unknown as Graph).node.bNode;
|
||||||
|
// The Sun's linear law, 1 − 0.6 (1 − μ): the centre at full brightness, the limb at 40 %, and
|
||||||
|
// past the silhouette, where the normal turns away, no darker than the limb.
|
||||||
|
expect(factorAt(factor, 1)).toBeCloseTo(1, 12);
|
||||||
|
expect(factorAt(factor, 0.5)).toBeCloseTo(0.7, 12);
|
||||||
|
expect(factorAt(factor, 0)).toBeCloseTo(0.4, 12);
|
||||||
|
expect(factorAt(factor, -0.3)).toBeCloseTo(0.4, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tints a host in the star field the colour its disc is drawn in, at the temperature the archive gives it', async () => {
|
||||||
|
// Proxima's B−V 1.8 reads 3 070 K; its disc is drawn at the archive's 2 900.
|
||||||
|
const scene = await enter(PROXIMA);
|
||||||
|
const field = (scene as unknown as { starField: { catalogueColors: Float32Array } }).starField.catalogueColors;
|
||||||
|
const at = STARS.indexOf(PROXIMA) * 3;
|
||||||
|
const disc = scene.starTint.value;
|
||||||
|
expect([field[at], field[at + 1], field[at + 2]].map((channel) => channel.toFixed(4))).toEqual([disc.r, disc.g, disc.b].map((channel) => channel.toFixed(4)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warms a host's planets by the luminosity the archive gives it, in the system as on their own page", async () => {
|
||||||
|
const scene = await enter(PROXIMA);
|
||||||
|
const planet = (scene.systemRenderer as unknown as { members: { id: string; marker: THREE.Mesh }[] }).members.find((member) => member.id === PROXIMA_B.id)!;
|
||||||
|
// The archive's 1.51×10⁻³ L☉ puts b at 228 K, temperate. The fixture has no measured band, so
|
||||||
|
// without it b gets no temperature and another class, as it does in the Sun's light.
|
||||||
|
const warmed = appearanceForExoplanet(PROXIMA_B, 0.00151);
|
||||||
|
expect(warmed.planetClass).not.toBe(appearanceForExoplanet(PROXIMA_B, null).planetClass);
|
||||||
|
expect(warmed.planetClass).not.toBe(appearanceForExoplanet(PROXIMA_B, 1).planetClass);
|
||||||
|
expect((planet.marker.material as THREE.MeshStandardMaterial).map).toBe(planetTexture(warmed, { width: 128, height: 64 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the camera three radii out from a supergiant drawn at the radius its type and brightness give', async () => {
|
||||||
|
const scene = await enter(ANTARES);
|
||||||
|
const radius = scene.starMarkerGeometry.parameters.radius;
|
||||||
|
// 410 R☉, 1.9 AU, at M1's 3 730 K: short of the 680 Ohnaka et al. (2013) measure, whose
|
||||||
|
// luminosity is 0.4 dex above what V gives at the catalogue's distance.
|
||||||
|
expect(radius / SUN_RADIUS_AU).toBeGreaterThan(350);
|
||||||
|
expect(radius / SUN_RADIUS_AU).toBeLessThan(480);
|
||||||
|
expect(scene.controls.minDistance).toBeCloseTo(closestApproachAu(radius), 9);
|
||||||
|
expect(scene.controls.minDistance).toBeGreaterThan(5);
|
||||||
|
// Settled where its disc stays inside the ring of its neighbours' names, not pressed up to it.
|
||||||
|
expect(engine.getCamera().position.length()).toBeGreaterThan(1.2 * scene.controls.minDistance);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("frames a supergiant on a phone by the canvas's own size, so its neighbours' names stay off its disc", async () => {
|
||||||
|
// A 390x844 canvas: framed as on a desktop, at half the half-side, the disc reached 98 px
|
||||||
|
// from the centre and the names hung in from their ring came to 70.
|
||||||
|
const canvas = (fixture.nativeElement as HTMLElement).querySelector('canvas')!;
|
||||||
|
Object.defineProperty(canvas, 'clientWidth', { value: 390 });
|
||||||
|
Object.defineProperty(canvas, 'clientHeight', { value: 844 });
|
||||||
|
const scene = await enter(ANTARES);
|
||||||
|
const camera = engine.getPerspectiveCamera();
|
||||||
|
const gridOuterRadiusAu = (scene.systemRenderer as unknown as { gridOuterRadiusAu: number }).gridOuterRadiusAu;
|
||||||
|
const radius = scene.starMarkerGeometry.parameters.radius;
|
||||||
|
const onPhone = systemFramingDistanceAu(gridOuterRadiusAu, { fovDegrees: camera.fov, aspect: camera.aspect, shorterSidePx: 390 }, radius);
|
||||||
|
expect(onPhone).toBeGreaterThan(1.5 * systemFramingDistanceAu(gridOuterRadiusAu, { fovDegrees: camera.fov, aspect: camera.aspect }, radius));
|
||||||
|
expect(engine.getCamera().position.length()).toBeCloseTo(onPhone, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('frames it by the shorter side on a phone held sideways too', async () => {
|
||||||
|
// 844x390: framed by the width, the disc took the desktop's half again, 8.49 AU out, not 14.97.
|
||||||
|
const canvas = (fixture.nativeElement as HTMLElement).querySelector('canvas')!;
|
||||||
|
Object.defineProperty(canvas, 'clientWidth', { value: 844 });
|
||||||
|
Object.defineProperty(canvas, 'clientHeight', { value: 390 });
|
||||||
|
const scene = await enter(ANTARES);
|
||||||
|
const camera = engine.getPerspectiveCamera();
|
||||||
|
const gridOuterRadiusAu = (scene.systemRenderer as unknown as { gridOuterRadiusAu: number }).gridOuterRadiusAu;
|
||||||
|
const radius = scene.starMarkerGeometry.parameters.radius;
|
||||||
|
expect(engine.getCamera().position.length()).toBeCloseTo(systemFramingDistanceAu(gridOuterRadiusAu, { fovDegrees: camera.fov, aspect: camera.aspect, shorterSidePx: 390 }, radius), 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a star nothing gives a size or temperature for as a grey point, not as the Sun', async () => {
|
||||||
|
const scene = await enter(PROCYON_B);
|
||||||
|
expect(scene.starMarkerGeometry.parameters.radius).toBeLessThan(SUN_RADIUS_AU / 1000);
|
||||||
|
expectColour(scene.starTint.value, [1, 1, 1]);
|
||||||
|
expect(scene.hudReadouts().some((readout) => readout.label === 'Radius')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores a new selection while a transition is already in flight, then resolves to the latest requested star once idle', async () => {
|
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);
|
navigationStore.selectStar(SUN.id);
|
||||||
await flushAsync();
|
await flushAsync();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as THREE from 'three/webgpu';
|
import * as THREE from 'three/webgpu';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { blackbodyColor, effectiveTemperatureK } from '../../shared/astro/stellar';
|
||||||
import { StarRecord } from '../../shared/models/star.model';
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
import { colorIndexToRgb, magnitudeToPointSize, selectDrawnStars, StarFieldRenderer } from './star-field-renderer';
|
import { colorIndexToRgb, magnitudeToPointSize, selectDrawnStars, StarFieldRenderer } from './star-field-renderer';
|
||||||
|
|
||||||
@@ -63,9 +64,9 @@ describe('colorIndexToRgb', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('matches the colour the same spectral type would give explicitly', () => {
|
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).
|
// K5's row of the dwarf sequence is at B−V 1.15.
|
||||||
const derived = colorIndexToRgb(null, 'K5');
|
const derived = colorIndexToRgb(null, 'K5');
|
||||||
const explicit = colorIndexToRgb(1.105);
|
const explicit = colorIndexToRgb(1.15);
|
||||||
|
|
||||||
expect(derived.r).toBeCloseTo(explicit.r, 6);
|
expect(derived.r).toBeCloseTo(explicit.r, 6);
|
||||||
expect(derived.g).toBeCloseTo(explicit.g, 6);
|
expect(derived.g).toBeCloseTo(explicit.g, 6);
|
||||||
@@ -85,6 +86,36 @@ describe('colorIndexToRgb', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tints a G dwarf warm, in the colour its own disc is drawn in, and a giant at the temperature its type gives', () => {
|
||||||
|
// G2 V: a blackbody at 5 770 K against D65, (1, 0.878, 0.821). The B−V ramp this replaced was
|
||||||
|
// white at 0.8 and gave it (0.956, 0.969, 1), bluish beside its own disc.
|
||||||
|
const g2 = colorIndexToRgb(0.823, 'Unknown', 'BP-RP');
|
||||||
|
expect(g2.r).toBeGreaterThan(g2.b);
|
||||||
|
expect([g2.r, g2.g, g2.b].map((channel) => channel.toFixed(5))).toEqual(blackbodyColor(5770).map((channel) => channel.toFixed(5)));
|
||||||
|
const antares = { magnitude: 1.06, distancePc: 169.78, spectralType: 'M1Ib + B2.5V', colorIndex: 1.865, colorSystem: 'B-V' } as const;
|
||||||
|
const giant = colorIndexToRgb(antares.colorIndex, antares.spectralType, antares.colorSystem);
|
||||||
|
blackbodyColor(effectiveTemperatureK(antares)!).forEach((channel, i) => expect([giant.r, giant.g, giant.b][i]).toBeCloseTo(channel, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a BP−RP colour as the B−V of the dwarf of that colour, so a type tints alike in either', () => {
|
||||||
|
// G2 V is B−V 0.65 or BP−RP 0.823, M0 V 1.42 or 1.84 (Pecaut & Mamajek).
|
||||||
|
for (const [bMinusV, bpRp] of [[0.65, 0.823], [1.42, 1.84]]) {
|
||||||
|
const [fromBpRp, fromBv] = [colorIndexToRgb(bpRp, undefined, 'BP-RP'), colorIndexToRgb(bMinusV)];
|
||||||
|
expect([fromBpRp.r, fromBpRp.g, fromBpRp.b].map((channel) => channel.toFixed(5))).toEqual([fromBv.r, fromBv.g, fromBv.b].map((channel) => channel.toFixed(5)));
|
||||||
|
expect(colorIndexToRgb(bpRp).b).toBeLessThan(fromBv.b);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a star measured in BP−RP in that colour', () => {
|
||||||
|
const gaia = star({ id: 5, x: 0, y: 0, z: -10, colorIndex: 1.84, colorSystem: 'BP-RP', spectralType: 'Unknown' });
|
||||||
|
const renderer = new StarFieldRenderer([gaia], packPositions([gaia]), 1);
|
||||||
|
renderer.refocus({ centre: { x: 0, y: 0, z: 0 } });
|
||||||
|
const { colorAttribute } = renderer as unknown as { colorAttribute: THREE.InstancedBufferAttribute };
|
||||||
|
expect(colorAttribute.getZ(0)).toBeCloseTo(colorIndexToRgb(1.42).b, 5);
|
||||||
|
expect(colorAttribute.getZ(0)).toBeCloseTo(blackbodyColor(3850)[2], 5);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it('prefers a measured index over the spectral type', () => {
|
it('prefers a measured index over the spectral type', () => {
|
||||||
const measured = colorIndexToRgb(-0.3, 'M5');
|
const measured = colorIndexToRgb(-0.3, 'M5');
|
||||||
expect(measured.b).toBeGreaterThan(measured.r);
|
expect(measured.b).toBeGreaterThan(measured.r);
|
||||||
@@ -172,6 +203,25 @@ describe('StarFieldRenderer', () => {
|
|||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores a star just outside the frame, however close the pointer gets to the edge', () => {
|
||||||
|
// Its hit area is the drawn size plus a slop, so near an edge that area reaches past the
|
||||||
|
// frame — and a system nobody can see is not one a click should fly into.
|
||||||
|
const offScreen = [star({ id: 9, x: 0, y: 0, z: -10, magnitude: -2 })];
|
||||||
|
const renderer = new StarFieldRenderer(offScreen, packPositions(offScreen));
|
||||||
|
const centre = new THREE.Vector3(0, 0, -10).project(camera);
|
||||||
|
expect(renderer.pickAt(new THREE.Vector2(centre.x, centre.y), camera, camera.aspect)).toBe(9);
|
||||||
|
|
||||||
|
// The same star, just outside the top of the frame: its centre at NDC 1.01, its disc ending at
|
||||||
|
// 1.0033. A click at 0.995 is within its hit radius (0.0167) — so without the frame test this
|
||||||
|
// picks it — while none of the star is on screen.
|
||||||
|
const above = [star({ id: 9, x: 0, y: 10 * Math.tan((camera.fov * Math.PI) / 360) * 1.01, z: -10, magnitude: -2 })];
|
||||||
|
const outside = new StarFieldRenderer(above, packPositions(above));
|
||||||
|
|
||||||
|
expect(outside.pickAt(new THREE.Vector2(0, 0.995), camera, camera.aspect)).toBeUndefined();
|
||||||
|
renderer.dispose();
|
||||||
|
outside.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it('picks the star nearest the pointer when several are in view', () => {
|
it('picks the star nearest the pointer when several are in view', () => {
|
||||||
const spread = [
|
const spread = [
|
||||||
star({ id: 1, x: 0, y: 0, z: -10 }),
|
star({ id: 1, x: 0, y: 0, z: -10 }),
|
||||||
@@ -441,7 +491,7 @@ describe('StarFieldRenderer refocus', () => {
|
|||||||
|
|
||||||
for (let instance = 0; instance < renderer.drawnCount; instance++) {
|
for (let instance = 0; instance < renderer.drawnCount; instance++) {
|
||||||
const drawnStar = catalogue.find((candidate) => candidate.id === renderer.starIdAt(instance))!;
|
const drawnStar = catalogue.find((candidate) => candidate.id === renderer.starIdAt(instance))!;
|
||||||
const expected = colorIndexToRgb(drawnStar.colorIndex, drawnStar.spectralType);
|
const expected = colorIndexToRgb(drawnStar.colorIndex, drawnStar.spectralType, drawnStar.colorSystem);
|
||||||
expect(colorAttribute.getX(instance)).toBeCloseTo(expected.r, 5);
|
expect(colorAttribute.getX(instance)).toBeCloseTo(expected.r, 5);
|
||||||
expect(colorAttribute.getZ(instance)).toBeCloseTo(expected.b, 5);
|
expect(colorAttribute.getZ(instance)).toBeCloseTo(expected.b, 5);
|
||||||
expect(sizeAttribute.getX(instance)).toBeGreaterThan(0);
|
expect(sizeAttribute.getX(instance)).toBeGreaterThan(0);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as THREE from 'three/webgpu';
|
|||||||
import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
|
import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
|
||||||
|
|
||||||
import { BrightnessIndex, brightnessIndex, Positioned } from '../../shared/astro/brightest';
|
import { BrightnessIndex, brightnessIndex, Positioned } from '../../shared/astro/brightest';
|
||||||
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
|
import { blackbodyColor, effectiveTemperatureK } from '../../shared/astro/stellar';
|
||||||
import { SceneCamera } from '../../core/engine/engine.service';
|
import { SceneCamera } from '../../core/engine/engine.service';
|
||||||
import { StarRecord } from '../../shared/models/star.model';
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size';
|
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size';
|
||||||
@@ -97,30 +97,46 @@ export interface DrawFocus {
|
|||||||
|
|
||||||
const SUN: Positioned = { x: 0, y: 0, z: 0 };
|
const SUN: Positioned = { x: 0, y: 0, z: 0 };
|
||||||
|
|
||||||
const COLD_STAR_COLOR = new THREE.Color(0.65, 0.75, 1.0);
|
|
||||||
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
|
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
|
||||||
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
|
* A star's tint in the field: the colour of a blackbody at its effective temperature against the
|
||||||
* blue-white, cool/red stars (high index) skew orange-red, matching real spectral colors.
|
* display's white — the tint its own disc is drawn in, in its system. For a star with no planets
|
||||||
|
* the temperature is the one the disc is drawn at (`effectiveTemperatureK`): off the dwarf sequence
|
||||||
|
* at the star's colour, in B−V or Gaia's BP−RP, off the type where there is no colour, and off the
|
||||||
|
* type for a giant. A host's disc is drawn at the archive's temperature instead, which the renderer
|
||||||
|
* is given apart and tints it at ({@link temperatureTint}).
|
||||||
*
|
*
|
||||||
* `colorIndex` is `null` for the ~10% of stars HYG never photometered. Those fall back to a
|
* `colorIndex` is `null` for the ~10% of stars HYG never photometered. Those fall back to their
|
||||||
* value derived from `spectralType`, and to neutral white only when the catalog records no
|
* `spectralType`, and to neutral white only when the catalog records no classification at all —
|
||||||
* classification at all — never to 0, which is itself a real color index meaning "hot A-type"
|
* never to 0, which is itself a real color index meaning "hot A-type" and would paint several
|
||||||
* and would paint several hundred red dwarfs blue-white.
|
* hundred red dwarfs blue-white.
|
||||||
|
*
|
||||||
|
* It was a ramp in B−V, white at 0.8, a K0 dwarf, where a blackbody against D65 is white at about
|
||||||
|
* 6 500 K, B−V 0.44: of the 376 660 stars measured in BP−RP, 245 850 were tinted bluish, 227 797 of
|
||||||
|
* them while their disc was warm, and a G2 dwarf (0.956, 0.969, 1) beside its disc's (1, 0.878,
|
||||||
|
* 0.821). Its red end, (1, 0.6, 0.35), was paler than an M dwarf's disc.
|
||||||
*/
|
*/
|
||||||
export function colorIndexToRgb(colorIndex: number | null, spectralType?: string): THREE.Color {
|
export function colorIndexToRgb(colorIndex: number | null, spectralType?: string, colorSystem?: 'B-V' | 'BP-RP'): THREE.Color {
|
||||||
const resolved = colorIndex ?? spectralTypeToColorIndex(spectralType);
|
// The distance is only there to say the star is not the Sun; the temperature reads none of the rest.
|
||||||
const color = new THREE.Color();
|
return temperatureTint(effectiveTemperatureK({ magnitude: 0, distancePc: 1, spectralType, colorIndex, colorSystem }));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The field's tint at a temperature: a blackbody against the display's white, neutral with none. */
|
||||||
|
export function temperatureTint(temperatureK: number | null): THREE.Color {
|
||||||
|
if (temperatureK === null) {
|
||||||
|
return new THREE.Color().copy(NEUTRAL_STAR_COLOR);
|
||||||
|
}
|
||||||
|
// ponytail: the colour at the nearest 10 K, computed once. Rounding moves no channel by more than
|
||||||
|
// 0.0014, and computing it for each of the 455 571 stars took 100 ms of the boot.
|
||||||
|
const step = Math.round(temperatureK / BLACKBODY_STEP_K);
|
||||||
|
const tint = (BLACKBODY_TINTS[step] ??= blackbodyColor(step * BLACKBODY_STEP_K));
|
||||||
|
return new THREE.Color(tint[0], tint[1], tint[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLACKBODY_STEP_K = 10;
|
||||||
|
const BLACKBODY_TINTS: [number, number, number][] = [];
|
||||||
|
|
||||||
/** Brighter stars (lower apparent magnitude) render as bigger points. */
|
/** Brighter stars (lower apparent magnitude) render as bigger points. */
|
||||||
export function magnitudeToPointSize(magnitude: number): number {
|
export function magnitudeToPointSize(magnitude: number): number {
|
||||||
const t = THREE.MathUtils.clamp(1 - (magnitude + 2) / 12, 0, 1);
|
const t = THREE.MathUtils.clamp(1 - (magnitude + 2) / 12, 0, 1);
|
||||||
@@ -281,7 +297,14 @@ export class StarFieldRenderer {
|
|||||||
private readonly catalogue: readonly StarRecord[],
|
private readonly catalogue: readonly StarRecord[],
|
||||||
private readonly cataloguePositions: Float32Array,
|
private readonly cataloguePositions: Float32Array,
|
||||||
budget = STAR_RENDER_BUDGET,
|
budget = STAR_RENDER_BUDGET,
|
||||||
brightness?: BrightnessIndex
|
brightness?: BrightnessIndex,
|
||||||
|
/**
|
||||||
|
* The temperature the archive gives each planet host, by star id, which its disc is drawn at
|
||||||
|
* (`publishedTemperaturesK`). Tinted off its colour instead, 1 755 of the 4 485 hosts differed
|
||||||
|
* from their own disc by more than 0.1 in RGB: Kepler-186 at 3 096 K in the field and 3 788 on
|
||||||
|
* its disc, HD 97048 at 6 825 and 10 000.
|
||||||
|
*/
|
||||||
|
publishedTemperaturesK?: ReadonlyMap<number, number>
|
||||||
) {
|
) {
|
||||||
this.budget = budget;
|
this.budget = budget;
|
||||||
this.brightness = brightness ?? brightnessIndex(catalogue);
|
this.brightness = brightness ?? brightnessIndex(catalogue);
|
||||||
@@ -295,7 +318,8 @@ export class StarFieldRenderer {
|
|||||||
this.catalogueColors = new Float32Array(catalogue.length * 3);
|
this.catalogueColors = new Float32Array(catalogue.length * 3);
|
||||||
this.catalogueSizes = new Float32Array(catalogue.length);
|
this.catalogueSizes = new Float32Array(catalogue.length);
|
||||||
catalogue.forEach((star, index) => {
|
catalogue.forEach((star, index) => {
|
||||||
const color = colorIndexToRgb(star.colorIndex, star.spectralType);
|
const published = publishedTemperaturesK?.get(star.id);
|
||||||
|
const color = published === undefined ? colorIndexToRgb(star.colorIndex, star.spectralType, star.colorSystem) : temperatureTint(published);
|
||||||
this.catalogueColors[index * 3] = color.r;
|
this.catalogueColors[index * 3] = color.r;
|
||||||
this.catalogueColors[index * 3 + 1] = color.g;
|
this.catalogueColors[index * 3 + 1] = color.g;
|
||||||
this.catalogueColors[index * 3 + 2] = color.b;
|
this.catalogueColors[index * 3 + 2] = color.b;
|
||||||
@@ -406,6 +430,11 @@ export class StarFieldRenderer {
|
|||||||
* needed: each star is tested against the size it is actually drawn at, so the hit area matches
|
* needed: each star is tested against the size it is actually drawn at, so the hit area matches
|
||||||
* what the user sees at every zoom level instead of being over-permissive up close and
|
* what the user sees at every zoom level instead of being over-permissive up close and
|
||||||
* sub-pixel at the far end of the camera's range.
|
* sub-pixel at the far end of the camera's range.
|
||||||
|
*
|
||||||
|
* Only stars on screen can be picked. The hit area is the drawn size plus a slop of
|
||||||
|
* {@link PICK_NDC_SLOP}, and near an edge that slop reaches past the frame: a click in the
|
||||||
|
* last few pixels of the view used to be able to fly into a system whose star was outside it,
|
||||||
|
* with nothing on screen to explain where it had gone.
|
||||||
*/
|
*/
|
||||||
pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined {
|
pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined {
|
||||||
// What a unit of angular size is worth on screen. Under perspective the field of view sets
|
// What a unit of angular size is worth on screen. Under perspective the field of view sets
|
||||||
@@ -429,10 +458,16 @@ export class StarFieldRenderer {
|
|||||||
if (projected.z < -1 || projected.z > 1) {
|
if (projected.z < -1 || projected.z > 1) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
|
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
|
||||||
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
|
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
|
||||||
const ndcRadius = (0.5 * sizes[index]) / tanHalfFov + PICK_NDC_SLOP;
|
const drawnRadius = (0.5 * sizes[index]) / tanHalfFov;
|
||||||
|
// Off screen if no part of the drawn disc is inside the frame. Tested before the slop is
|
||||||
|
// added: the slop is forgiveness for an imprecise click on a star you can see, not a reach
|
||||||
|
// past the edge to one you cannot.
|
||||||
|
if (Math.abs(projected.x) - drawnRadius / aspect > 1 || Math.abs(projected.y) - drawnRadius > 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ndcRadius = drawnRadius + PICK_NDC_SLOP;
|
||||||
const dx = (projected.x - pointerNdc.x) * aspect;
|
const dx = (projected.x - pointerNdc.x) * aspect;
|
||||||
const dy = projected.y - pointerNdc.y;
|
const dy = projected.y - pointerNdc.y;
|
||||||
const score = Math.hypot(dx, dy) / ndcRadius;
|
const score = Math.hypot(dx, dy) / ndcRadius;
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
|
import { catalogueCensus, describingCatalogue, positionsNote, starReadouts, starSubtitle } from './star-readouts';
|
||||||
|
|
||||||
|
/** Three kinds of star the catalogue holds, each as the decoder gives it back. */
|
||||||
|
const HYG_STAR: StarRecord = {
|
||||||
|
id: 32263,
|
||||||
|
name: 'Sirius',
|
||||||
|
x: -0.49,
|
||||||
|
y: 2.47,
|
||||||
|
z: -0.75,
|
||||||
|
magnitude: -1.44,
|
||||||
|
magnitudeBand: 'V',
|
||||||
|
spectralType: 'A0m',
|
||||||
|
colorIndex: 0.009,
|
||||||
|
colorSystem: 'B-V',
|
||||||
|
distanceError: 0.004,
|
||||||
|
distanceFromGaia: false,
|
||||||
|
source: 'hyg'
|
||||||
|
};
|
||||||
|
// A star Gaia alone knows, at 117 pc and good to a tenth.
|
||||||
|
const GAIA_STAR: StarRecord = {
|
||||||
|
id: 1000000001,
|
||||||
|
name: 'Gaia DR3 5612323414549657984',
|
||||||
|
x: 117,
|
||||||
|
y: 0,
|
||||||
|
z: 0,
|
||||||
|
magnitude: 11.2,
|
||||||
|
magnitudeBand: 'G',
|
||||||
|
spectralType: 'Unknown',
|
||||||
|
colorIndex: 1.43,
|
||||||
|
colorSystem: 'BP-RP',
|
||||||
|
distanceError: 0.1,
|
||||||
|
distanceFromGaia: true,
|
||||||
|
source: 'gaia'
|
||||||
|
};
|
||||||
|
// HYG's description of Proxima, at the place and distance Gaia measures.
|
||||||
|
const PLACED_BY_GAIA: StarRecord = { ...HYG_STAR, name: 'Proxima Centauri', magnitude: 11.01, colorIndex: 1.807, spectralType: 'M5Ve', distanceFromGaia: true, source: 'gaia' };
|
||||||
|
|
||||||
|
function value(readouts: { label: string; value: string }[], label: string): string | undefined {
|
||||||
|
return readouts.find((readout) => readout.label === label)?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('starReadouts', () => {
|
||||||
|
it('names the band of the magnitude, and which colour the colour index is', () => {
|
||||||
|
expect(value(starReadouts(HYG_STAR), 'Magnitude')).toBe('V -1.44');
|
||||||
|
expect(value(starReadouts(GAIA_STAR), 'Magnitude')).toBe('G 11.20');
|
||||||
|
expect(value(starReadouts(HYG_STAR), 'Colour')).toBe('B−V 0.01');
|
||||||
|
expect(value(starReadouts(GAIA_STAR), 'Colour')).toBe('BP−RP 1.43');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says a colour read off a temperature was not measured', () => {
|
||||||
|
const kepler445 = { ...HYG_STAR, magnitudeBand: 'G' as const, colorIndex: 1.66, colorFromTemperature: true, source: 'exoplanet-archive' };
|
||||||
|
expect(starReadouts(kepler445).find((readout) => readout.label === 'Colour')).toEqual({ label: 'Colour', value: 'B−V 1.66, from its temperature', derived: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says a stand-in magnitude was not measured, and leaves out a colour there is none of', () => {
|
||||||
|
const unmeasured = starReadouts({ ...GAIA_STAR, magnitude: 12, magnitudeBand: undefined, colorIndex: null, colorSystem: undefined });
|
||||||
|
expect(value(unmeasured, 'Magnitude')).toBe('Not measured');
|
||||||
|
expect(value(unmeasured, 'Colour')).toBeUndefined();
|
||||||
|
// Still Gaia's star, as the 44 Gaia sources with no G are: no band is not HYG's V.
|
||||||
|
expect(value(unmeasured, 'Source')).toBe('Gaia DR3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the distance with its uncertainty', () => {
|
||||||
|
expect(value(starReadouts(GAIA_STAR), 'Distance')).toBe('117 ± 12 pc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives an archive star's distance error as the archive does, on the distance, not as a parallax range", () => {
|
||||||
|
// KMT-2016-BLG-1836L, 7 100 +800 −2 400 pc: a mean of 22.5 %.
|
||||||
|
const lens = { ...GAIA_STAR, x: 7100, magnitudeBand: undefined, colorIndex: null, colorSystem: undefined, distanceError: 0.2254, distanceFromGaia: false, source: 'exoplanet-archive' };
|
||||||
|
expect(value(starReadouts(lens), 'Distance')).toBe('7.1 ± 1.6 kpc');
|
||||||
|
expect(value(starReadouts({ ...lens, source: 'hyg' }), 'Distance')).toBe('5.8 kpc to 9.2 kpc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the catalogue a star comes from, and whose distance it has', () => {
|
||||||
|
expect(value(starReadouts(GAIA_STAR), 'Source')).toBe('Gaia DR3');
|
||||||
|
expect(value(starReadouts(HYG_STAR), 'Source')).toBe('HYG');
|
||||||
|
expect(value(starReadouts(PLACED_BY_GAIA), 'Source')).toBe('HYG, Gaia DR3 distance');
|
||||||
|
expect(value(starReadouts({ ...HYG_STAR, source: 'exoplanet-archive' }), 'Source')).toBe('NASA Exoplanet Archive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says a radius was derived, and from what; a published one plainly', () => {
|
||||||
|
expect(starReadouts(PLACED_BY_GAIA, { radiusSolar: 0.1049, radiusDerived: true, temperatureK: 3068, luminositySolar: null, luminosityDerived: true }).find((readout) => readout.label === 'Radius')).toEqual({
|
||||||
|
label: 'Radius',
|
||||||
|
value: '~0.10 solar radii, from colour and brightness',
|
||||||
|
derived: true
|
||||||
|
});
|
||||||
|
expect(value(starReadouts(PLACED_BY_GAIA, { radiusSolar: 0.141, radiusDerived: false, temperatureK: 2900, luminositySolar: null, luminosityDerived: true }), 'Radius')).toBe('0.141 solar radii');
|
||||||
|
// A giant's temperature is its type's whatever its colour, and so is a dwarf's with no colour.
|
||||||
|
const betelgeuse = { ...HYG_STAR, name: 'Betelgeuse', spectralType: 'M1-M2Ia-Iab', colorIndex: 1.85 };
|
||||||
|
expect(value(starReadouts(betelgeuse, { radiusSolar: 584.3, radiusDerived: true, temperatureK: 3590, luminositySolar: null, luminosityDerived: true }), 'Radius')).toBe('~580 solar radii, from its type and brightness');
|
||||||
|
const gj3655 = { ...HYG_STAR, name: 'GJ 3655', spectralType: 'M8', colorIndex: null, colorSystem: undefined };
|
||||||
|
expect(value(starReadouts(gj3655, { radiusSolar: 0.106, radiusDerived: true, temperatureK: 2570, luminositySolar: null, luminosityDerived: true }), 'Radius')).toBe('~0.11 solar radii, from its type and brightness');
|
||||||
|
// Nor a dwarf's whose colour is off the table: HD 49748, G5 V at B−V −0.32.
|
||||||
|
const hd49748 = { ...HYG_STAR, name: 'HD 49748', spectralType: 'G5V', colorIndex: -0.32 };
|
||||||
|
expect(value(starReadouts(hd49748, { radiusSolar: 1.2, radiusDerived: true, temperatureK: 5660, luminositySolar: null, luminosityDerived: true }), 'Radius')).toBe('~1.2 solar radii, from its type and brightness');
|
||||||
|
const kepler445 = { ...HYG_STAR, spectralType: 'M4', colorIndex: 1.66, colorFromTemperature: true, source: 'exoplanet-archive' };
|
||||||
|
expect(value(starReadouts(kepler445, { radiusSolar: 0.21, radiusDerived: true, temperatureK: 3157, luminositySolar: null, luminosityDerived: true }), 'Radius')).toBe('~0.21 solar radii, from its temperature and brightness');
|
||||||
|
expect(value(starReadouts(HYG_STAR, { radiusSolar: 1, radiusDerived: false, temperatureK: 5772, luminositySolar: null, luminosityDerived: true }), 'Radius')).toBe('1.00 solar radii');
|
||||||
|
expect(starReadouts(HYG_STAR, { radiusSolar: null, radiusDerived: true, temperatureK: null, luminositySolar: null, luminosityDerived: true }).some((readout) => readout.label === 'Radius')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a derived luminosity so, and a published one not', () => {
|
||||||
|
const surface = { radiusSolar: null, radiusDerived: true, temperatureK: null };
|
||||||
|
expect(starReadouts(HYG_STAR, { ...surface, luminositySolar: 25.4, luminosityDerived: true }).find((readout) => readout.label === 'Luminosity')).toEqual({ label: 'Luminosity', value: '25.40 L☉', derived: true });
|
||||||
|
expect(starReadouts(PLACED_BY_GAIA, { ...surface, luminositySolar: 0.00151, luminosityDerived: false }).find((readout) => readout.label === 'Luminosity')).toEqual({ label: 'Luminosity', value: '0.0015 L☉' });
|
||||||
|
expect(starReadouts(HYG_STAR, { ...surface, luminositySolar: null, luminosityDerived: true }).some((readout) => readout.label === 'Luminosity')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('positionsNote', () => {
|
||||||
|
it("says which positions are the archive's distances rather than parallaxes, and how many", () => {
|
||||||
|
expect(positionsNote([HYG_STAR, GAIA_STAR])).toBe('Positions from measured parallaxes. Grid marks the galactic plane through the Sun.');
|
||||||
|
expect(positionsNote([HYG_STAR, { ...GAIA_STAR, source: 'exoplanet-archive' }])).toBe(
|
||||||
|
'Positions from measured parallaxes; for the 1 planet hosts only the NASA Exoplanet Archive places, from its distances. Grid marks the galactic plane through the Sun.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says which stars sit at the Gliese catalogue's distances, many of them no parallax, and leaves the Sun out", () => {
|
||||||
|
// A HYG row with neither a Hipparcos nor a Gaia error: GJ 3522, at the 4.46 pc of a parallax CNS3 estimates.
|
||||||
|
const gliese: StarRecord = { ...HYG_STAR, id: 900, name: 'GJ 3522', distanceError: undefined };
|
||||||
|
const sun: StarRecord = { ...HYG_STAR, id: 0, name: 'Sol', distanceError: undefined };
|
||||||
|
expect(positionsNote([sun, HYG_STAR, gliese, { ...GAIA_STAR, source: 'exoplanet-archive' }])).toBe(
|
||||||
|
'Positions from measured parallaxes; for the 1 stars only the Gliese catalogue places, from its distances, about half of them photometric; ' +
|
||||||
|
'for the 1 planet hosts only the NASA Exoplanet Archive places, from its distances. Grid marks the galactic plane through the Sun.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('starSubtitle', () => {
|
||||||
|
it("prints the catalogue's classification where it has one", () => {
|
||||||
|
expect(starSubtitle(HYG_STAR)).toBe('Spectral type A0m');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("estimates one from the colour otherwise, in the colour's own system, and says so", () => {
|
||||||
|
// 1.43 is a K5 dwarf in BP−RP; read as B−V it would be an M0.
|
||||||
|
expect(starSubtitle(GAIA_STAR)).toBe('Spectral type ~K5, from colour');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says an estimate came from the temperature where the colour was read off it', () => {
|
||||||
|
// PSR J1719-1438: no magnitude in any colour, st_teff 4 500 K, B−V 1.13 off the dwarf sequence.
|
||||||
|
const pulsar = { ...GAIA_STAR, source: 'exoplanet-archive', colorIndex: 1.128, colorSystem: 'B-V', colorFromTemperature: true } as const;
|
||||||
|
expect(starSubtitle(pulsar)).toBe('Spectral type ~K5, from its temperature');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints nothing rather than "Unknown" when there is neither', () => {
|
||||||
|
expect(starSubtitle({ ...GAIA_STAR, colorIndex: null, colorSystem: undefined })).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('catalogueCensus', () => {
|
||||||
|
it('counts the stars by the catalogue describing them, not by whose position they have', () => {
|
||||||
|
expect(describingCatalogue(PLACED_BY_GAIA)).toBe('HYG');
|
||||||
|
expect(catalogueCensus([GAIA_STAR, GAIA_STAR, GAIA_STAR, HYG_STAR, PLACED_BY_GAIA])).toBe('Gaia DR3 3 · HYG 2');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { spectralClassification } from '../../shared/astro/spectral';
|
||||||
|
import { temperatureFromColour } from '../../shared/astro/stellar';
|
||||||
|
import { formatDistance, formatLuminosity } from '../../shared/format/quantity';
|
||||||
|
import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model';
|
||||||
|
import { StarSurface } from '../body-detail/body-view-model';
|
||||||
|
import { HudReadout } from '../hud/hud-dock.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalogue that describes a star — its name, type and photometry — as the readout names it.
|
||||||
|
*
|
||||||
|
* Not the same as `source`, which records whose *position* the star has: 62 097 stars HYG
|
||||||
|
* describes sit where Gaia places them, and carry `gaia`. What gives them away is their V
|
||||||
|
* magnitude, which only HYG and the archive measure and the archive's stars have their own source.
|
||||||
|
*/
|
||||||
|
export function describingCatalogue(star: StarRecord): string {
|
||||||
|
if (star.source === 'exoplanet-archive') {
|
||||||
|
return 'NASA Exoplanet Archive';
|
||||||
|
}
|
||||||
|
return star.source === 'gaia' && star.magnitudeBand !== 'V' ? 'Gaia DR3' : 'HYG';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the readout says a star is: the catalogue's classification, or — for the 83 % of stars
|
||||||
|
* that have none, every Gaia star among them — the dwarf type its colour matches, marked as an
|
||||||
|
* estimate. Empty with neither, rather than the ETL's literal "Unknown". Where the colour was itself
|
||||||
|
* read off the archive's temperature, the estimate says so: 30 archive hosts measured in no colour
|
||||||
|
* read "~X, from colour", PSR J1719-1438, a pulsar the archive gives 4 500 K, "~K5, from colour".
|
||||||
|
*/
|
||||||
|
export function starSubtitle(star: StarRecord): string {
|
||||||
|
const classification = spectralClassification(star);
|
||||||
|
const basis = star.colorFromTemperature ? ', from its temperature' : ', from colour';
|
||||||
|
return !classification ? '' : `Spectral type ${classification}${classification.startsWith('~') ? basis : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A star's measured readouts, each with what it was measured in: the band of its magnitude, which
|
||||||
|
* colour its colour index is, the distance's uncertainty, and the catalogues they come from.
|
||||||
|
* The luminosity and radius are the archive's where it publishes them, and otherwise derived and
|
||||||
|
* marked so; a derived radius also says from what.
|
||||||
|
*/
|
||||||
|
export function starReadouts(star: StarRecord, surface?: StarSurface): HudReadout[] {
|
||||||
|
const distancePc = Math.hypot(star.x, star.y, star.z);
|
||||||
|
const catalogue = describingCatalogue(star);
|
||||||
|
return [
|
||||||
|
// Suppressed for the Sun rather than printed as `0.00 pc`, which is arithmetically right
|
||||||
|
// and reads as a bug: the distance from here to here is not a measurement.
|
||||||
|
...(distancePc > 0 ? [{ label: 'Distance', value: formatDistance(distancePc, star.distanceError, star.source === 'exoplanet-archive') }] : []),
|
||||||
|
// A G magnitude and a V one are not comparable: a red dwarf is up to three brighter in G.
|
||||||
|
{ label: 'Magnitude', value: star.magnitudeBand ? `${star.magnitudeBand} ${star.magnitude.toFixed(2)}` : 'Not measured' },
|
||||||
|
...(star.colorIndex !== null
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: 'Colour',
|
||||||
|
value: `${star.colorSystem === 'BP-RP' ? 'BP−RP' : 'B−V'} ${star.colorIndex.toFixed(2)}${star.colorFromTemperature ? ', from its temperature' : ''}`,
|
||||||
|
...(star.colorFromTemperature ? { derived: true } : {})
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(surface?.luminositySolar
|
||||||
|
? [{ label: 'Luminosity', value: formatLuminosity(surface.luminositySolar), ...(surface.luminosityDerived ? { derived: true } : {}) }]
|
||||||
|
: []),
|
||||||
|
...(surface?.radiusSolar ? [radiusReadout(surface.radiusSolar, surface.radiusDerived, radiusBasis(star))] : []),
|
||||||
|
{ label: 'Source', value: catalogue === 'HYG' && star.distanceFromGaia ? 'HYG, Gaia DR3 distance' : catalogue }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a derived radius is worked out from besides the brightness: the temperature the star's
|
||||||
|
* colour gives, or its type's — a giant's always, and a dwarf's with no colour the table reads.
|
||||||
|
* 10 953 radii read "from colour" whose temperature no colour went into: 10 713 giants with one,
|
||||||
|
* 214 stars with none, GJ 3655 (M8) among them, and 26 dwarfs whose colour is off the table.
|
||||||
|
*/
|
||||||
|
function radiusBasis(star: StarRecord): string {
|
||||||
|
if (star.colorFromTemperature) {
|
||||||
|
return 'its temperature';
|
||||||
|
}
|
||||||
|
return temperatureFromColour(star) ? 'colour' : 'its type';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two figures for a derived radius, three for a published one: 0.105 is not what colour gives. */
|
||||||
|
function radiusReadout(radiusSolar: number, derived: boolean, basis: string): HudReadout {
|
||||||
|
const digits = derived ? 2 : 3;
|
||||||
|
const figure = radiusSolar.toLocaleString('en-GB', { minimumSignificantDigits: digits, maximumSignificantDigits: digits });
|
||||||
|
return derived
|
||||||
|
? { label: 'Radius', value: `~${figure} solar radii, from ${basis} and brightness`, derived: true }
|
||||||
|
: { label: 'Radius', value: `${figure} solar radii` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the neighbourhood's positions come from: parallaxes, except for the stars only the
|
||||||
|
* Exoplanet Archive places, which sit at its own distances — a lensing model's for the
|
||||||
|
* microlensing hosts among them, OGLE-2005-BLG-390L's 6.6 kpc for one, with no parallax behind it —
|
||||||
|
* and the HYG stars neither Hipparcos nor Gaia measured, which sit at the Gliese catalogue's. Those
|
||||||
|
* have no published error, and of the 313 on the map before 63 were folded into the Gaia source SIMBAD names
|
||||||
|
* them as (250 now), about 154 had a photometric or spectroscopic parallax in CNS3 (Gliese &
|
||||||
|
* Jahreiss 1991), none measured: GJ 3522 at 4.46 pc is 1000/224 mas.
|
||||||
|
*/
|
||||||
|
export function positionsNote(stars: readonly StarRecord[]): string {
|
||||||
|
const archive = stars.filter((star) => star.source === 'exoplanet-archive').length;
|
||||||
|
const gliese = stars.filter((star) => star.source === 'hyg' && star.distanceError === undefined && star.id !== SUN_STAR_ID).length;
|
||||||
|
const where = [
|
||||||
|
'Positions from measured parallaxes',
|
||||||
|
...(gliese === 0 ? [] : [`for the ${gliese.toLocaleString('en-GB')} stars only the Gliese catalogue places, from its distances, about half of them photometric`]),
|
||||||
|
...(archive === 0 ? [] : [`for the ${archive.toLocaleString('en-GB')} planet hosts only the NASA Exoplanet Archive places, from its distances`])
|
||||||
|
].join('; ');
|
||||||
|
return `${where}. Grid marks the galactic plane through the Sun.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the catalogue holds, counted by the catalogue describing each star, largest first. */
|
||||||
|
export function catalogueCensus(stars: readonly StarRecord[]): string {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const star of stars) {
|
||||||
|
const catalogue = describingCatalogue(star);
|
||||||
|
counts.set(catalogue, (counts.get(catalogue) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...counts]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([catalogue, count]) => `${catalogue} ${count.toLocaleString('en-GB')}`)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
@@ -4,9 +4,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
||||||
import {
|
import {
|
||||||
bodyMarkerRadiusAu,
|
bodyMarkerRadiusAu,
|
||||||
DEFAULT_STAR_MARKER_RADIUS_AU,
|
closestApproachAu,
|
||||||
starGlowExtentAu,
|
|
||||||
starMarkerRadiusAu,
|
|
||||||
systemFrameRadiusAu,
|
systemFrameRadiusAu,
|
||||||
systemFramingDistanceAu,
|
systemFramingDistanceAu,
|
||||||
systemGridRingsAu,
|
systemGridRingsAu,
|
||||||
@@ -19,39 +17,10 @@ import {
|
|||||||
const TRAPPIST_1 = { innermost: 0.01154, outermost: 0.06189 };
|
const TRAPPIST_1 = { innermost: 0.01154, outermost: 0.06189 };
|
||||||
const GL_357 = { innermost: 0.035, outermost: 0.204 };
|
const GL_357 = { innermost: 0.035, outermost: 0.204 };
|
||||||
const SOLAR = { innermost: 0.387, outermost: 30.07 };
|
const SOLAR = { innermost: 0.387, outermost: 30.07 };
|
||||||
|
/** The solar system as the map draws it: out to Eris's semi-major axis, 67.93 AU. */
|
||||||
describe('starMarkerRadiusAu', () => {
|
const SOLAR_TO_ERIS = { innermost: 0.387, outermost: 67.93 };
|
||||||
it('never reaches the innermost orbit', () => {
|
/** Eris's aphelion, a(1 + e) = 67.934 x 1.4382: past the 80 AU ring its semi-major axis gives. */
|
||||||
for (const { innermost } of [TRAPPIST_1, GL_357, SOLAR]) {
|
const ERIS_APHELION_AU = 97.7;
|
||||||
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', () => {
|
describe('systemFramingDistanceAu', () => {
|
||||||
it('fits the radius it is given in view, with room around it', () => {
|
it('fits the radius it is given in view, with room around it', () => {
|
||||||
@@ -89,6 +58,41 @@ describe('systemFramingDistanceAu', () => {
|
|||||||
expect(systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 2.5 })).toBeCloseTo(square, 9);
|
expect(systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 2.5 })).toBeCloseTo(square, 9);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('backs off to hold a giant wider than its system, and leaves a dwarf to the system', () => {
|
||||||
|
// Betelgeuse drawn at 584 solar radii, 2.7 AU, with nothing around it; the Sun inside its own.
|
||||||
|
const betelgeuseAu = 2.72;
|
||||||
|
expect(systemFrameRadiusAu(systemFramingDistanceAu(0, undefined, betelgeuseAu))).toBeGreaterThan(betelgeuseAu);
|
||||||
|
expect(systemFrameRadiusAu(systemFramingDistanceAu(0.5, undefined, betelgeuseAu))).toBeGreaterThan(betelgeuseAu);
|
||||||
|
expect(systemFramingDistanceAu(SOLAR.outermost, undefined, 0.00465)).toBe(systemFramingDistanceAu(SOLAR.outermost));
|
||||||
|
expect(systemFramingDistanceAu(0, undefined, 0.00465)).toBe(systemFramingDistanceAu(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a giant's disc clear of its neighbours' names on a phone, which hang a fixed 78 px in from their ring", () => {
|
||||||
|
// Betelgeuse at 390x844 and at 1600x1000. Kept to half the half-side, its disc was 98 px in radius
|
||||||
|
// on the phone, where the names come within 66 px of the centre.
|
||||||
|
const betelgeuseAu = 2.72;
|
||||||
|
for (const [width, height] of [[390, 844], [1600, 1000]]) {
|
||||||
|
const viewport = { fovDegrees: 50, aspect: width / height, shorterSidePx: Math.min(width, height) };
|
||||||
|
const distance = systemFramingDistanceAu(0, viewport, betelgeuseAu);
|
||||||
|
const tight = Math.tan((25 * Math.PI) / 180) * Math.min(1, viewport.aspect);
|
||||||
|
const halfSidePx = viewport.shorterSidePx / 2;
|
||||||
|
const discPx = (Math.tan(Math.asin(betelgeuseAu / distance)) / tight) * halfSidePx;
|
||||||
|
expect(discPx).toBeLessThan(0.74 * halfSidePx - 78);
|
||||||
|
expect(discPx).toBeGreaterThan(0.2 * halfSidePx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds a giant's disc inside the ring its neighbours are named on, and the camera clear of its closest approach", () => {
|
||||||
|
// The ring is at 0.74 of the tighter half-extent; the disc is kept to half of it, in either window.
|
||||||
|
const betelgeuseAu = 2.72;
|
||||||
|
for (const viewport of [{ fovDegrees: 50, aspect: 1.6 }, { fovDegrees: 50, aspect: 0.6 }]) {
|
||||||
|
const distance = systemFramingDistanceAu(0, viewport, betelgeuseAu);
|
||||||
|
const tight = Math.tan((25 * Math.PI) / 180) * Math.min(1, viewport.aspect);
|
||||||
|
expect(Math.tan(Math.asin(betelgeuseAu / distance)) / tight).toBeCloseTo(0.5, 9);
|
||||||
|
expect(distance).toBeGreaterThan(closestApproachAu(betelgeuseAu));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('caps the distance so a far-flung companion cannot shrink the star to nothing', () => {
|
it('caps the distance so a far-flung companion cannot shrink the star to nothing', () => {
|
||||||
expect(systemFramingDistanceAu(1000)).toBe(systemFramingDistanceAu(5000));
|
expect(systemFramingDistanceAu(1000)).toBe(systemFramingDistanceAu(5000));
|
||||||
});
|
});
|
||||||
@@ -113,107 +117,12 @@ describe('systemFramingDistanceAu', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('starGlowExtentAu', () => {
|
|
||||||
/** A typical viewport, so a screen-space claim can be made in pixels rather than in ratios. */
|
|
||||||
const REFERENCE_VIEWPORT_HALF_HEIGHT_PX = 450;
|
|
||||||
|
|
||||||
/** The halo's visual radius, in AU, at the distance this system is framed from. */
|
|
||||||
function haloRadiusAu(innermostAu: number, outermostAu: number, glowScale = 1): number {
|
|
||||||
// The sprite's extent is its full width, so half of it is what reaches out from the star.
|
|
||||||
return starGlowExtentAu(starMarkerRadiusAu(innermostAu), frameRadiusFor(outermostAu), glowScale) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
function frameRadiusFor(outermostAu: number): number {
|
|
||||||
const rings = systemGridRingsAu(outermostAu);
|
|
||||||
return systemFrameRadiusAu(systemFramingDistanceAu(rings[rings.length - 1]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Apparent size on screen, as a fraction of the frame's half-height. */
|
|
||||||
function apparentFraction(innermostAu: number, outermostAu: number, glowScale = 1): number {
|
|
||||||
return haloRadiusAu(innermostAu, outermostAu, glowScale) / frameRadiusFor(outermostAu);
|
|
||||||
}
|
|
||||||
|
|
||||||
function apparentPixels(innermostAu: number, outermostAu: number): number {
|
|
||||||
return apparentFraction(innermostAu, outermostAu) * REFERENCE_VIEWPORT_HALF_HEIGHT_PX;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('scales with the star for a compact system, where the star is already big enough', () => {
|
|
||||||
// A tight frame relative to the star, so the star's own multiple is what decides.
|
|
||||||
const marker = 0.02;
|
|
||||||
const tightFrame = 0.5;
|
|
||||||
expect(starGlowExtentAu(marker, tightFrame)).toBeCloseTo(marker * 3.2, 9);
|
|
||||||
expect(starGlowExtentAu(marker * 2, tightFrame)).toBeCloseTo(marker * 2 * 3.2, 9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('floors against the frame once the star would otherwise vanish into it', () => {
|
|
||||||
// A star sized against a close-in orbit, framed from far enough out to hold a wide system:
|
|
||||||
// the multiple of the star is nothing, so the frame decides instead.
|
|
||||||
const tinyStar = 0.001;
|
|
||||||
const wideFrame = 56;
|
|
||||||
expect(starGlowExtentAu(tinyStar, wideFrame)).toBeGreaterThan(tinyStar * 3.2 * 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the Sun visible at the distance that frames the solar system', () => {
|
|
||||||
// The case that prompted this: the solar system spans a factor of a hundred from Mercury to
|
|
||||||
// Pluto, so a disc that stays clear of Mercury is about a pixel across once Pluto is in view.
|
|
||||||
expect(apparentPixels(0.387, 39.288)).toBeGreaterThan(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves the inner orbits clear of the halo', () => {
|
|
||||||
// The other half of the same trade. Venus and Earth have to stay legible as rings around the
|
|
||||||
// star, which bounds the halo from above just as visibility bounds it from below.
|
|
||||||
const halo = haloRadiusAu(0.387, 39.288);
|
|
||||||
const VENUS_AU = 0.723;
|
|
||||||
const EARTH_AU = 1;
|
|
||||||
expect(halo).toBeLessThan(VENUS_AU);
|
|
||||||
expect(halo).toBeLessThan(EARTH_AU);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cannot clear Mercury as well, and does not pretend to', () => {
|
|
||||||
// Mercury's orbit is 0.7% of the framed radius — about three pixels — so it is inside any
|
|
||||||
// halo big enough to see. Pinned so the trade is a decision rather than an oversight.
|
|
||||||
expect(haloRadiusAu(0.387, 39.288)).toBeGreaterThan(0.387);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('holds the floor across every system scale the datasets contain', () => {
|
|
||||||
// A compact system's star is genuinely large relative to its own system and keeps the bigger
|
|
||||||
// halo; the floor is not there to equalise them, only to stop the wide ones disappearing.
|
|
||||||
for (const [innermost, outermost] of [
|
|
||||||
[0.387, 39.288],
|
|
||||||
[0.035, 0.204],
|
|
||||||
[0.01154, 0.06189],
|
|
||||||
[1.2, 12.4]
|
|
||||||
]) {
|
|
||||||
expect(apparentPixels(innermost, outermost)).toBeGreaterThan(4);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not blot out the system it sits in', () => {
|
|
||||||
for (const [innermost, outermost] of [
|
|
||||||
[0.387, 39.288],
|
|
||||||
[0.035, 0.204],
|
|
||||||
[0.01154, 0.06189]
|
|
||||||
]) {
|
|
||||||
expect(apparentFraction(innermost, outermost)).toBeLessThan(0.2);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('dims for a star drawn from a colour rather than a photograph, but never below the floor', () => {
|
|
||||||
// Above the floor the multiplier applies...
|
|
||||||
expect(starGlowExtentAu(1, 10, 0.6)).toBeLessThan(starGlowExtentAu(1, 10, 1));
|
|
||||||
// ...and at the floor it cannot dim a star into invisibility.
|
|
||||||
expect(starGlowExtentAu(0.001, 56, 0.6)).toBe(starGlowExtentAu(0.001, 56, 1));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the star alone when there is no frame to measure against', () => {
|
|
||||||
for (const frame of [0, -1, Number.NaN]) {
|
|
||||||
expect(starGlowExtentAu(0.2, frame)).toBeCloseTo(0.2 * 3.2, 9);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('the grid and the framing together', () => {
|
describe('the grid and the framing together', () => {
|
||||||
/** What the scene actually composes: rings from the orbits, then a distance from the rings. */
|
/**
|
||||||
|
* The grid's half of what the scene composes: rings from the orbits, then a distance from the outer
|
||||||
|
* ring. The scene frames the larger of that ring and the furthest aphelion (`outermostRadiusAu`),
|
||||||
|
* which the Eris test below frames where it runs past the ring, and the scene's own spec checks.
|
||||||
|
*/
|
||||||
function fit(outermostOrbitAu: number, viewport?: SystemViewport): { ring: number; frame: number } {
|
function fit(outermostOrbitAu: number, viewport?: SystemViewport): { ring: number; frame: number } {
|
||||||
const rings = systemGridRingsAu(outermostOrbitAu);
|
const rings = systemGridRingsAu(outermostOrbitAu);
|
||||||
const ring = rings[rings.length - 1];
|
const ring = rings[rings.length - 1];
|
||||||
@@ -223,14 +132,15 @@ describe('the grid and the framing together', () => {
|
|||||||
const VIEWPORTS: SystemViewport[] = [
|
const VIEWPORTS: SystemViewport[] = [
|
||||||
{ fovDegrees: 50, aspect: 1.78 },
|
{ fovDegrees: 50, aspect: 1.78 },
|
||||||
{ fovDegrees: 50, aspect: 1 },
|
{ fovDegrees: 50, aspect: 1 },
|
||||||
{ fovDegrees: 50, aspect: 0.6 }
|
{ fovDegrees: 50, aspect: 0.6 },
|
||||||
|
{ fovDegrees: 50, aspect: 390 / 844 } // a phone held upright
|
||||||
];
|
];
|
||||||
|
|
||||||
it('leaves the outermost ring clear of the frame edge at every scale and window shape', () => {
|
it('leaves the outermost ring clear of the frame edge at every scale and window shape', () => {
|
||||||
// The whole point of framing against the grid rather than the orbits: before this, 368 of
|
// The whole point of framing against the grid rather than the orbits: before this, 368 of
|
||||||
// the 371 systems in the datasets drew a grid wider than the view that was meant to hold it.
|
// the 371 systems in the datasets drew a grid wider than the view that was meant to hold it.
|
||||||
for (const viewport of VIEWPORTS) {
|
for (const viewport of VIEWPORTS) {
|
||||||
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) {
|
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, SOLAR_TO_ERIS, { outermost: 1 }, { outermost: 12.4 }]) {
|
||||||
const { ring, frame } = fit(outermost, viewport);
|
const { ring, frame } = fit(outermost, viewport);
|
||||||
expect(ring).toBeLessThan(frame);
|
expect(ring).toBeLessThan(frame);
|
||||||
expect(ring / frame).toBeLessThan(0.93);
|
expect(ring / frame).toBeLessThan(0.93);
|
||||||
@@ -238,6 +148,14 @@ describe('the grid and the framing together', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leaves Eris’s aphelion its whole margin in every window shape, a phone held upright included', () => {
|
||||||
|
// The scene frames the aphelion where it runs past the ring. Under the old 500 AU ceiling the
|
||||||
|
// phone would hold it at 0.907 of the half-width instead of 1 / 1.12 = 0.893.
|
||||||
|
for (const viewport of VIEWPORTS) {
|
||||||
|
expect(ERIS_APHELION_AU / systemFrameRadiusAu(systemFramingDistanceAu(ERIS_APHELION_AU, viewport), viewport)).toBeLessThan(0.9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('still encloses the outermost orbit, so no planet sits off the edge of the grid', () => {
|
it('still encloses the outermost orbit, so no planet sits off the edge of the grid', () => {
|
||||||
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) {
|
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) {
|
||||||
expect(fit(outermost).ring).toBeGreaterThan(outermost);
|
expect(fit(outermost).ring).toBeGreaterThan(outermost);
|
||||||
@@ -254,78 +172,50 @@ describe('the grid and the framing together', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('star and framing together', () => {
|
describe('closestApproachAu', () => {
|
||||||
it('gives compact and wide systems a comparable apparent star size', () => {
|
it('keeps the camera three radii out from a giant, and at the old floor for the Sun', () => {
|
||||||
// Both scale with the system, so the star subtends a similar angle either way — the point
|
expect(closestApproachAu(0.00465)).toBe(0.05);
|
||||||
// of deriving them from the same measurements rather than fixing them.
|
expect(closestApproachAu(2.72)).toBeCloseTo(8.16, 9);
|
||||||
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', () => {
|
describe('bodyMarkerRadiusAu', () => {
|
||||||
const EARTH_RADIUS_KM = 6371;
|
const EARTH_RADIUS_KM = 6371;
|
||||||
const SOLAR_SPAN_AU = 30.07;
|
const KM_PER_AU = 149597870.7;
|
||||||
|
|
||||||
it('scales in proportion to the system span', () => {
|
it('draws a body at its true size', () => {
|
||||||
const wide = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU);
|
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM)).toBeCloseTo(EARTH_RADIUS_KM / KM_PER_AU, 12);
|
||||||
const compact = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU / 100);
|
expect(bodyMarkerRadiusAu(696340)).toBeCloseTo(0.00465, 5); // the Sun
|
||||||
|
|
||||||
expect(compact / wide).toBeCloseTo(0.01, 6);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a marker far smaller than the orbits it sits on, at any scale', () => {
|
it('keeps a moon smaller than its planet and outside it, which the exaggeration did not', () => {
|
||||||
// A fixed 0.09 AU marker inside Gl 357's 0.204 AU system was wider than the orbits, so one
|
// Jupiter and Ganymede both ran past the old 0.09 AU ceiling and came out one size, so
|
||||||
// planet swallowed the whole view.
|
// Ganymede orbited inside Jupiter; Phobos and Triton sat entirely within Mars and Neptune.
|
||||||
for (const span of [0.06, 0.204, 1, 30.07, 800]) {
|
const jupiter = bodyMarkerRadiusAu(69911);
|
||||||
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeLessThan(span / 5);
|
const ganymede = bodyMarkerRadiusAu(2634);
|
||||||
}
|
const callisto = bodyMarkerRadiusAu(2410);
|
||||||
|
const GANYMEDE_SEMI_MAJOR_AXIS_AU = 0.007155;
|
||||||
|
|
||||||
|
expect(ganymede).toBeLessThan(jupiter);
|
||||||
|
expect(callisto).toBeLessThan(ganymede);
|
||||||
|
expect(jupiter + ganymede).toBeLessThan(GANYMEDE_SEMI_MAJOR_AXIS_AU);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gives compact and wide systems the same apparent marker size', () => {
|
it('keeps Phobos outside Mars, where a marker scaled to the system buried it', () => {
|
||||||
const apparent = (span: number) => bodyMarkerRadiusAu(EARTH_RADIUS_KM, span) / systemFramingDistanceAu(span);
|
const PHOBOS_SEMI_MAJOR_AXIS_AU = 0.00006268;
|
||||||
|
|
||||||
expect(apparent(0.204)).toBeCloseTo(apparent(10), 6);
|
expect(bodyMarkerRadiusAu(3390) + bodyMarkerRadiusAu(11.27)).toBeLessThan(PHOBOS_SEMI_MAJOR_AXIS_AU);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still renders a bigger body as a bigger marker', () => {
|
it('still renders a bigger body as a bigger marker', () => {
|
||||||
const jupiter = bodyMarkerRadiusAu(69911, SOLAR_SPAN_AU);
|
expect(bodyMarkerRadiusAu(69911)).toBeGreaterThan(bodyMarkerRadiusAu(1188));
|
||||||
const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU);
|
|
||||||
|
|
||||||
expect(jupiter).toBeGreaterThan(pluto);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the smallest marker for a body with no known radius', () => {
|
it('falls back to an Earth for a body with no published radius', () => {
|
||||||
const unknown = bodyMarkerRadiusAu(undefined, SOLAR_SPAN_AU);
|
for (const nothing of [undefined, 0, -1]) {
|
||||||
const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU);
|
expect(bodyMarkerRadiusAu(nothing as number | undefined)).toBeCloseTo(EARTH_RADIUS_KM / KM_PER_AU, 12);
|
||||||
|
|
||||||
expect(unknown).toBeGreaterThan(0);
|
|
||||||
expect(unknown).toBeLessThanOrEqual(pluto);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats a missing span as the reference scale rather than collapsing to zero', () => {
|
|
||||||
for (const span of [0, -5, Number.NaN]) {
|
|
||||||
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeGreaterThan(0);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves the solar system essentially as it was before scaling', () => {
|
|
||||||
// The constants were tuned at this span, so the scale factor here is ~1.
|
|
||||||
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU)).toBeCloseTo(0.09, 2);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('systemGridRingsAu', () => {
|
describe('systemGridRingsAu', () => {
|
||||||
|
|||||||
@@ -12,46 +12,11 @@ import { CartesianCoordinates } from '../../shared/astro/coordinates';
|
|||||||
* the star marker, so they rendered as a lone sphere with nothing around it, and 52% were
|
* 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.
|
* 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
|
* The camera's distance is therefore derived from the system's own scale. The star is not: it is
|
||||||
* camera scale together, a compact system ends up looking like a wide one: same apparent star,
|
* drawn at its own radius, like every body here, and the framing only makes room for it when the
|
||||||
* same apparent spread of orbits.
|
* star is a giant wider than its system.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 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
|
* Clear space left around the framed radius, as a fraction of it. The camera backs off this
|
||||||
* much further than the geometry strictly needs, so the outermost ring sits inside the frame
|
* much further than the geometry strictly needs, so the outermost ring sits inside the frame
|
||||||
@@ -68,6 +33,8 @@ const FRAME_MARGIN = 0.12;
|
|||||||
export interface SystemViewport {
|
export interface SystemViewport {
|
||||||
fovDegrees: number;
|
fovDegrees: number;
|
||||||
aspect: number;
|
aspect: number;
|
||||||
|
/** The canvas's shorter side in CSS pixels, which a giant's disc is kept clear of its neighbours' names on. */
|
||||||
|
shorterSidePx?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SYSTEM_VIEWPORT: SystemViewport = { fovDegrees: 50, aspect: 1 };
|
export const DEFAULT_SYSTEM_VIEWPORT: SystemViewport = { fovDegrees: 50, aspect: 1 };
|
||||||
@@ -86,13 +53,49 @@ const MIN_FRAMING_DISTANCE_AU = 0.06;
|
|||||||
/**
|
/**
|
||||||
* Ceiling on the framing distance, so a distant companion does not push the star to a dot.
|
* Ceiling on the framing distance, so a distant companion does not push the star to a dot.
|
||||||
*
|
*
|
||||||
* Generous enough to frame the solar system out to Pluto in any window shape, which needs 120 AU
|
* Generous enough to frame the solar system out to Eris in any window a reader holds: Eris's
|
||||||
* on a landscape display and 140 on a portrait one once the camera's real field of view is
|
* aphelion, 97.7 AU, the furthest it draws, needs 235 AU on a landscape display and 508 on a 390
|
||||||
* accounted for. Only genuinely pathological systems reach it now — the handful with
|
* by 844 phone once the camera's real field of view is accounted for, and 600 holds it down to an
|
||||||
* directly-imaged companions hundreds of AU out — and those still arrive framed on their inner
|
* aspect of 0.39. At 500, framed on the 80 AU grid ring inside that aphelion, a phone arrived with
|
||||||
* region, with the orbit controls reaching far enough to pull back to the rest.
|
* Eris's orbit 3.5 px from the edge; at 200, which framed Pluto's 40 AU ring, a portrait window
|
||||||
|
* arrived with Eris off screen, and a phone with Makemake too. Only genuinely pathological systems
|
||||||
|
* reach it now — the handful with directly-imaged companions hundreds of AU out — and those still
|
||||||
|
* arrive framed on their inner region, with the orbit controls reaching far enough to pull back
|
||||||
|
* to the rest.
|
||||||
*/
|
*/
|
||||||
const MAX_FRAMING_DISTANCE_AU = 200;
|
const MAX_FRAMING_DISTANCE_AU = 600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of the view's tighter half-extent a giant's disc may take on arrival: inside the ring
|
||||||
|
* the system view names the star's neighbours on, and clear of the names hung inward from it,
|
||||||
|
* whose nearest corners come within 292 px of the centre on a 1 000 px view. Framed to fill the
|
||||||
|
* frame instead, Betelgeuse settled at the three-radius closest approach with a disc 379 px in
|
||||||
|
* radius, past the ring 370 px out, and its neighbours' names on it.
|
||||||
|
*/
|
||||||
|
const STAR_FRAME_FRACTION = 0.5;
|
||||||
|
/**
|
||||||
|
* The ring the system view names a star's neighbours on, as a fraction of the frame's shorter
|
||||||
|
* half-side (see `ringPlacement`). Clear of the scale rail at the top and the dock at the bottom.
|
||||||
|
*/
|
||||||
|
export const NEIGHBOUR_RING_FRACTION = 0.74;
|
||||||
|
/**
|
||||||
|
* How far a neighbour's name reaches in from that ring, in pixels whatever the window: its nearest
|
||||||
|
* corner measured 74 px in on a 390 px phone and 78 px on a 1 000 px view, and a margin on that.
|
||||||
|
* The fraction above left the names 0.24 of the half-side, 47 px on a phone, and at 390x844
|
||||||
|
* Betelgeuse's disc, 98 px in radius, had HD 39374's name 70 px from its centre.
|
||||||
|
*/
|
||||||
|
const NAME_REACH_PX = 90;
|
||||||
|
/** However small the window, the disc still takes this much of it. */
|
||||||
|
const MIN_STAR_FRAME_FRACTION = 0.1;
|
||||||
|
|
||||||
|
/** The fraction of the tighter half-extent a giant's disc may take in this viewport. */
|
||||||
|
function starFrameFraction(viewport: SystemViewport): number {
|
||||||
|
if (!viewport.shorterSidePx) {
|
||||||
|
return STAR_FRAME_FRACTION;
|
||||||
|
}
|
||||||
|
const clear = NEIGHBOUR_RING_FRACTION - NAME_REACH_PX / (viewport.shorterSidePx / 2);
|
||||||
|
return Math.min(STAR_FRAME_FRACTION, Math.max(MIN_STAR_FRAME_FRACTION, clear));
|
||||||
|
}
|
||||||
|
|
||||||
/** Framing for a star with no known planets, where there is nothing to fit. */
|
/** Framing for a star with no known planets, where there is nothing to fit. */
|
||||||
const EMPTY_SYSTEM_FRAMING_DISTANCE_AU = 3;
|
const EMPTY_SYSTEM_FRAMING_DISTANCE_AU = 3;
|
||||||
@@ -128,20 +131,6 @@ export function systemViewDirection(referenceFrame: THREE.Quaternion): THREE.Vec
|
|||||||
return new THREE.Vector3(x, y, z).normalize().applyQuaternion(referenceFrame);
|
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
|
* 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.
|
* view frustum where the system sits, along whichever screen axis is tighter.
|
||||||
@@ -150,20 +139,6 @@ export function systemFrameRadiusAu(distanceAu: number, viewport: SystemViewport
|
|||||||
return distanceAu * tightHalfExtent(viewport);
|
return distanceAu * tightHalfExtent(viewport);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extent (AU) of the star's glow sprite — how wide it is drawn, not its radius.
|
|
||||||
*
|
|
||||||
* Normally a multiple of the star's own radius, so a compact system keeps the corona it has.
|
|
||||||
* Floored against the framed radius, so a star framed from far enough out to hold its whole
|
|
||||||
* system still reads as a bright point rather than disappearing into it. `glowScale` lets a
|
|
||||||
* caller dim the halo for stars drawn without a real photograph.
|
|
||||||
*/
|
|
||||||
export function starGlowExtentAu(markerRadiusAu: number, frameRadiusAu: number, glowScale = 1): number {
|
|
||||||
const fromStar = markerRadiusAu * STAR_GLOW_TO_MARKER * glowScale;
|
|
||||||
const fromFrame = Number.isFinite(frameRadiusAu) && frameRadiusAu > 0 ? frameRadiusAu * MIN_STAR_GLOW_TO_FRAME : 0;
|
|
||||||
return Math.max(fromStar, fromFrame);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Distance (AU) to settle the camera at so that `framedRadiusAu` fits in view with a margin
|
* Distance (AU) to settle the camera at so that `framedRadiusAu` fits in view with a margin
|
||||||
* around it.
|
* around it.
|
||||||
@@ -174,17 +149,37 @@ export function starGlowExtentAu(markerRadiusAu: number, frameRadiusAu: number,
|
|||||||
* was tuned by eye against a 55-degree field, and the engine's camera is 50 — which left the
|
* was tuned by eye against a 55-degree field, and the engine's camera is 50 — which left the
|
||||||
* grid overflowing the frame in 368 of the 371 systems the datasets contain.
|
* grid overflowing the frame in 368 of the 371 systems the datasets contain.
|
||||||
*
|
*
|
||||||
* Callers pass the outermost thing actually drawn, which is the reference grid's outer ring
|
* Callers pass the outermost thing actually drawn: the reference grid's outer ring, which runs past
|
||||||
* rather than the outermost orbit — the ring is always the wider of the two, by construction.
|
* every semi-major axis by construction, or an eccentric orbit's aphelion where that runs past the
|
||||||
|
* ring, as Eris's does.
|
||||||
*/
|
*/
|
||||||
export function systemFramingDistanceAu(framedRadiusAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT): number {
|
export function systemFramingDistanceAu(framedRadiusAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT, starRadiusAu = 0): number {
|
||||||
|
// A giant drawn at its own radius can be wider than the system around it — Betelgeuse's 584
|
||||||
|
// solar radii are 2.7 AU — or than the empty framing. Its disc is a sphere's, whose silhouette
|
||||||
|
// from d subtends asin(R / d): the distance that makes it the fraction above of the view.
|
||||||
|
const star = starRadiusAu * Math.sqrt(1 + 1 / (starFrameFraction(viewport) * tightHalfExtent(viewport)) ** 2);
|
||||||
if (!Number.isFinite(framedRadiusAu) || framedRadiusAu <= 0) {
|
if (!Number.isFinite(framedRadiusAu) || framedRadiusAu <= 0) {
|
||||||
return EMPTY_SYSTEM_FRAMING_DISTANCE_AU;
|
return Math.max(EMPTY_SYSTEM_FRAMING_DISTANCE_AU, star);
|
||||||
}
|
}
|
||||||
const required = (framedRadiusAu * (1 + FRAME_MARGIN)) / tightHalfExtent(viewport);
|
const required = Math.max((framedRadiusAu * (1 + FRAME_MARGIN)) / tightHalfExtent(viewport), star);
|
||||||
return clamp(required, MIN_FRAMING_DISTANCE_AU, MAX_FRAMING_DISTANCE_AU);
|
return clamp(required, MIN_FRAMING_DISTANCE_AU, MAX_FRAMING_DISTANCE_AU);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How close the camera may come to the star's centre, whatever the star: ten solar radii. */
|
||||||
|
const MIN_APPROACH_AU = 0.05;
|
||||||
|
/** From three radii out a star spans 39 degrees, most of the view's 50, and the camera stays out of it. */
|
||||||
|
const STAR_CLEARANCE_RADII = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The orbit controls' minimum distance in a system. The fixed 0.05 AU it used to be leaves the
|
||||||
|
* Sun 11 degrees across; but 23 211 stars on the map are drawn wider than 3.6 solar radii, which
|
||||||
|
* puts 0.05 AU inside three of their radii, and a giant's surface further out still — a zoom
|
||||||
|
* would have carried the camera through it.
|
||||||
|
*/
|
||||||
|
export function closestApproachAu(starRadiusAu: number): number {
|
||||||
|
return Math.max(MIN_APPROACH_AU, STAR_CLEARANCE_RADII * starRadiusAu);
|
||||||
|
}
|
||||||
|
|
||||||
/** Roughly how many rings the system grid aims for, and how far past the outermost orbit it runs. */
|
/** 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 TARGET_GRID_RING_COUNT = 8;
|
||||||
const GRID_EXTENT_TO_OUTERMOST_ORBIT = 1.15;
|
const GRID_EXTENT_TO_OUTERMOST_ORBIT = 1.15;
|
||||||
@@ -223,32 +218,30 @@ export function systemGridRingsAu(outermostOrbitAu: number): number[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Span of the solar system, in AU, used as the reference every other system's marker sizes are
|
* A body is drawn at its true size. Astronomical Unit in kilometres, and what a body with neither
|
||||||
* scaled against. The marker constants below were tuned by eye at this scale.
|
* a radius nor a mass to estimate one from is drawn as: an Earth, for want of anything better —
|
||||||
|
* exoplanets with a mass and no radius get an estimate from their mass before they reach here.
|
||||||
*/
|
*/
|
||||||
const REFERENCE_SYSTEM_SPAN_AU = 30;
|
const KM_PER_AU = 149597870.7;
|
||||||
|
const DEFAULT_BODY_RADIUS_KM = 6371;
|
||||||
/** Exaggerated (non-physical) marker sizes at the reference scale, so planets stay visible. */
|
|
||||||
const MIN_MARKER_RADIUS_AU = 0.012;
|
|
||||||
const MAX_MARKER_RADIUS_AU = 0.09;
|
|
||||||
/** Physical radius (km) that maps to one AU of marker radius before clamping. */
|
|
||||||
const MARKER_RADIUS_KM_PER_AU = 18000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Radius (AU) to draw a planet, moon or exoplanet marker at, scaled to the system it sits in.
|
* The Sun's own radius, in AU: the unit every star's radius is drawn in, the archive's or the one
|
||||||
*
|
* `starSurfaceOf` derives from its colour and brightness.
|
||||||
* 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 {
|
export const SUN_RADIUS_AU = 696340 / KM_PER_AU;
|
||||||
const span = Number.isFinite(systemSpanAu) && systemSpanAu > 0 ? systemSpanAu : REFERENCE_SYSTEM_SPAN_AU;
|
|
||||||
const atReferenceScale = radiusKm ? clamp(radiusKm / MARKER_RADIUS_KM_PER_AU, MIN_MARKER_RADIUS_AU, MAX_MARKER_RADIUS_AU) : MIN_MARKER_RADIUS_AU;
|
|
||||||
|
|
||||||
return atReferenceScale * (span / REFERENCE_SYSTEM_SPAN_AU);
|
/**
|
||||||
|
* Radius (AU) to draw a planet, moon or exoplanet marker at: its own, unexaggerated.
|
||||||
|
*
|
||||||
|
* Sizes used to be exaggerated and scaled to the system span, which is what made a moon the size
|
||||||
|
* of its planet — Jupiter and Ganymede both ran past the ceiling and were drawn at one radius, so
|
||||||
|
* every moon orbited inside its parent. True scale needs no rule to prevent that: physics already
|
||||||
|
* puts a moon outside the planet it orbits, and the Sun at a hundredth of Mercury’s orbit.
|
||||||
|
*
|
||||||
|
* What true scale costs is visibility at the framing that holds a whole system, where every body
|
||||||
|
* is sub-pixel. That is paid for on screen instead, in pixels, by the scene's `keepMarkersLegible`.
|
||||||
|
*/
|
||||||
|
export function bodyMarkerRadiusAu(radiusKm: number | undefined): number {
|
||||||
|
return (radiusKm && radiusKm > 0 ? radiusKm : DEFAULT_BODY_RADIUS_KM) / KM_PER_AU;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ describe('SystemObjectCardComponent', () => {
|
|||||||
expect(render(bare).textContent).not.toContain('Measured');
|
expect(render(bare).textContent).not.toContain('Measured');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('wraps a long designation rather than cutting off the digits that tell it apart', () => {
|
||||||
|
const host = render({ ...earth, name: '2MASS J21252752-8138278 b', hostStarName: '2MASS J21252752-8138278' });
|
||||||
|
const name = host.querySelector('[data-testid="object-card-name"]')!;
|
||||||
|
expect(name.textContent?.trim()).toBe('2MASS J21252752-8138278 b');
|
||||||
|
for (const line of [name, name.nextElementSibling!]) {
|
||||||
|
expect(line.classList).not.toContain('truncate');
|
||||||
|
expect(line.classList).toContain('wrap-break-word');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('says a photographed surface is a photograph', () => {
|
it('says a photographed surface is a photograph', () => {
|
||||||
expect(render(earth).textContent).toContain('photography');
|
expect(render(earth).textContent).toContain('photography');
|
||||||
});
|
});
|
||||||
@@ -98,7 +108,9 @@ describe('SystemObjectCardComponent', () => {
|
|||||||
appearance: appearance({ equilibriumTemperatureK: null }),
|
appearance: appearance({ equilibriumTemperatureK: null }),
|
||||||
});
|
});
|
||||||
expect(block(host, 'Derived')).not.toContain('Equilibrium temp.');
|
expect(block(host, 'Derived')).not.toContain('Equilibrium temp.');
|
||||||
expect(host.textContent).toContain('host star is not in the catalogue');
|
// Not that the host is missing, which it is for 27 of the 2 714 planets without a temperature.
|
||||||
|
expect(host.textContent).toContain('its star’s luminosity or its orbit’s size is not known');
|
||||||
|
expect(host.textContent).not.toContain('not in the catalogue');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits rather than navigating, so the scene decides what selection means', () => {
|
it('emits rather than navigating, so the scene decides what selection means', () => {
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
|||||||
<div data-testid="object-card" class="hud-brackets hud-acquire hud-surface font-body text-text">
|
<div data-testid="object-card" class="hud-brackets hud-acquire hud-surface font-body text-text">
|
||||||
<div class="flex items-start justify-between gap-3 px-4 pt-4 pb-3">
|
<div class="flex items-start justify-between gap-3 px-4 pt-4 pb-3">
|
||||||
<header class="min-w-0">
|
<header class="min-w-0">
|
||||||
<p class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</p>
|
<!-- Wrapped, not truncated, as on the detail page: a designation's last digits are the
|
||||||
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
ones that tell it from its neighbours. -->
|
||||||
|
<p data-testid="object-card-name" class="text-lg leading-tight font-bold tracking-[0.04em] wrap-break-word text-text uppercase">{{ body().name }}</p>
|
||||||
|
<p class="type-eyebrow mt-1 wrap-break-word text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||||
</header>
|
</header>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import * as THREE from 'three/webgpu';
|
/// <reference types="node" />
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { DEFAULT_EPOCH_JD } from '../../shared/astro/constants';
|
import { readFileSync } from 'node:fs';
|
||||||
import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
import * as THREE from 'three/webgpu';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { DEFAULT_EPOCH_JD, GM_SUN_AU3_PER_DAY2, ttMinusUtSeconds } from '../../shared/astro/constants';
|
||||||
|
import { keplerRates } from '../../shared/astro/kepler';
|
||||||
|
import { eclipticToEquatorial, laplacePlaneToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
||||||
|
import { orientationAt } from '../../shared/astro/rotational-elements';
|
||||||
import { BodyRecord } from '../../shared/models/body.model';
|
import { BodyRecord } from '../../shared/models/body.model';
|
||||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||||
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
||||||
|
import { bodyTexturePath, loadCachedTexture } from '../../shared/rendering/texture-catalog';
|
||||||
|
import { bodyMarkerRadiusAu } from './system-framing';
|
||||||
|
|
||||||
|
/** The clock's UT date that names a TDB one: TT - UT, which moves by under a second a year, earlier. */
|
||||||
|
const utOf = (jdTdb: number): number => jdTdb - ttMinusUtSeconds(jdTdb) / 86400;
|
||||||
|
|
||||||
/** TRAPPIST-1 b: a real short-period planet around a 0.09 solar-mass red dwarf. */
|
/** TRAPPIST-1 b: a real short-period planet around a 0.09 solar-mass red dwarf. */
|
||||||
const TRAPPIST_1B_SEMI_MAJOR_AXIS_AU = 0.01154;
|
const TRAPPIST_1B_SEMI_MAJOR_AXIS_AU = 0.01154;
|
||||||
@@ -164,7 +174,8 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => {
|
|||||||
argumentOfPeriapsisDeg: 0,
|
argumentOfPeriapsisDeg: 0,
|
||||||
meanAnomalyAtEpochDeg: 0,
|
meanAnomalyAtEpochDeg: 0,
|
||||||
epochJd: DEFAULT_EPOCH_JD
|
epochJd: DEFAULT_EPOCH_JD
|
||||||
}
|
},
|
||||||
|
rates: keplerRates(1, GM_SUN_AU3_PER_DAY2), orbitSource: 'test'
|
||||||
};
|
};
|
||||||
|
|
||||||
it('places an ecliptic orbit in the ecliptic plane of the equatorial scene', () => {
|
it('places an ecliptic orbit in the ecliptic plane of the equatorial scene', () => {
|
||||||
@@ -201,7 +212,8 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => {
|
|||||||
// A body at ecliptic longitude 0 sits on the +X axis in both frames, so it must not move.
|
// A body at ecliptic longitude 0 sits on the +X axis in both frames, so it must not move.
|
||||||
const atEquinox: BodyRecord = { ...EARTH, orbit: { ...EARTH.orbit, eccentricity: 0 } };
|
const atEquinox: BodyRecord = { ...EARTH, orbit: { ...EARTH.orbit, eccentricity: 0 } };
|
||||||
const renderer = new SystemOrbitsRenderer([atEquinox], []);
|
const renderer = new SystemOrbitsRenderer([atEquinox], []);
|
||||||
renderer.update(DEFAULT_EPOCH_JD);
|
// The clock's UT date whose TDB is the elements' epoch.
|
||||||
|
renderer.update(utOf(DEFAULT_EPOCH_JD));
|
||||||
|
|
||||||
const p = renderer.members[0].marker.position;
|
const p = renderer.members[0].marker.position;
|
||||||
expect(p.x).toBeCloseTo(1, 6);
|
expect(p.x).toBeCloseTo(1, 6);
|
||||||
@@ -234,7 +246,9 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => {
|
|||||||
name: 'Jupiter',
|
name: 'Jupiter',
|
||||||
kind: 'planet',
|
kind: 'planet',
|
||||||
radiusKm: 69911,
|
radiusKm: 69911,
|
||||||
orbit: { semiMajorAxisAu: 5.2, eccentricity: 0.048, inclinationDeg: 1.3, longitudeOfAscendingNodeDeg: 100, argumentOfPeriapsisDeg: 275, meanAnomalyAtEpochDeg: 20, epochJd: DEFAULT_EPOCH_JD }
|
orbit: { semiMajorAxisAu: 5.2, eccentricity: 0.048, inclinationDeg: 1.3, longitudeOfAscendingNodeDeg: 100, argumentOfPeriapsisDeg: 275, meanAnomalyAtEpochDeg: 20, epochJd: DEFAULT_EPOCH_JD },
|
||||||
|
rates: keplerRates(5.2, GM_SUN_AU3_PER_DAY2),
|
||||||
|
orbitSource: 'test'
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The grid and the tethers are the only line objects the renderer adds outside a pivot. */
|
/** The grid and the tethers are the only line objects the renderer adds outside a pivot. */
|
||||||
@@ -372,3 +386,648 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('rotation without IAU elements', () => {
|
||||||
|
/** A body with a day of 23.934 h and no pole: Eris, Haumea and Makemake are drawn this way. */
|
||||||
|
function spinning(overrides: Partial<BodyRecord> = {}): BodyRecord {
|
||||||
|
return {
|
||||||
|
id: 'earth',
|
||||||
|
systemStarId: 0,
|
||||||
|
name: 'Earth',
|
||||||
|
kind: 'planet',
|
||||||
|
radiusKm: 6371,
|
||||||
|
orbit: { semiMajorAxisAu: 1, eccentricity: 0.0167, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD },
|
||||||
|
rates: keplerRates(1, GM_SUN_AU3_PER_DAY2), orbitSource: 'test',
|
||||||
|
rotationPeriodHours: 23.934,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far the marker has turned about its own axis between two dates, in degrees. */
|
||||||
|
function turnedDegrees(body: BodyRecord, afterDays: number): number {
|
||||||
|
const renderer = new SystemOrbitsRenderer([body], [], undefined, 1);
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD);
|
||||||
|
const start = renderer.members[0].marker.quaternion.clone();
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD + afterDays);
|
||||||
|
const turn = start.invert().multiply(renderer.members[0].marker.quaternion);
|
||||||
|
const axis = new THREE.Vector3();
|
||||||
|
const angle = 2 * Math.acos(Math.min(1, Math.abs(turn.w)));
|
||||||
|
turn.normalize();
|
||||||
|
axis.set(turn.x, turn.y, turn.z);
|
||||||
|
const signed = axis.y >= 0 ? angle : -angle;
|
||||||
|
return (signed * 180) / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('turns a body once per its own sidereal day', () => {
|
||||||
|
// A full turn in 23.934 h, so a quarter of that is a quarter turn.
|
||||||
|
expect(Math.abs(turnedDegrees(spinning(), 23.934 / 96))).toBeCloseTo(90, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which way a body spins in the world: its angular velocity projected on its orbit's normal.
|
||||||
|
* Positive is prograde, turning the same way it goes round; negative is retrograde.
|
||||||
|
*/
|
||||||
|
function spinSense(body: BodyRecord): number {
|
||||||
|
const renderer = new SystemOrbitsRenderer([body], [], undefined, 1);
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD);
|
||||||
|
const start = renderer.members[0].marker.quaternion.clone();
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD + 0.01);
|
||||||
|
const turn = renderer.members[0].marker.quaternion.clone().multiply(start.invert());
|
||||||
|
const axis = new THREE.Vector3(turn.x, turn.y, turn.z).multiplyScalar(Math.sign(turn.w));
|
||||||
|
return axis.normalize().dot(new THREE.Vector3(0, 0, 1).applyQuaternion(renderer.referenceFrame));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('turns it about its orbit’s normal, backwards for a negative period', () => {
|
||||||
|
expect(spinSense(spinning({ rotationPeriodHours: -23.934 }))).toBeLessThan(-0.99);
|
||||||
|
expect(spinSense(spinning({ rotationPeriodHours: 23.934 }))).toBeGreaterThan(0.99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns Nereid, as shipped, once in the 11.594 hours Kepler measured: a sixth of a turn in 1.93 hours', () => {
|
||||||
|
const shipped: BodyRecord[] = JSON.parse(readFileSync(`${process.cwd()}/src/assets/data/bodies.json`, 'utf8'));
|
||||||
|
const renderer = new SystemOrbitsRenderer(shipped.filter((body) => body.id === 'neptune' || body.id === 'nereid'), []);
|
||||||
|
const nereid = renderer.members.find((member) => member.id === 'nereid')!.marker;
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD);
|
||||||
|
const start = nereid.quaternion.clone();
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD + 11.594 / 6 / 24);
|
||||||
|
expect((nereid.quaternion.angleTo(start) * 180) / Math.PI).toBeCloseTo(60, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a body with no published rotation still', () => {
|
||||||
|
// Hyperion, which tumbles: an invented period would be a claim.
|
||||||
|
const renderer = new SystemOrbitsRenderer([spinning({ rotationPeriodHours: undefined })], [], undefined, 1);
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD);
|
||||||
|
const start = renderer.members[0].marker.quaternion.clone();
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD + 40);
|
||||||
|
|
||||||
|
expect(renderer.members[0].marker.quaternion.angleTo(start)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('outermostRadiusAu', () => {
|
||||||
|
function drawn(axis: number, eccentricity: number): BodyRecord {
|
||||||
|
return {
|
||||||
|
id: 'eris', systemStarId: 0, name: 'Eris', kind: 'dwarf', radiusKm: 1163, orbitSource: 'test',
|
||||||
|
orbit: { semiMajorAxisAu: axis, eccentricity, inclinationDeg: 44, longitudeOfAscendingNodeDeg: 36, argumentOfPeriapsisDeg: 151, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD },
|
||||||
|
rates: keplerRates(axis, GM_SUN_AU3_PER_DAY2)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reaches as far as an eccentric orbit goes past the grid: Eris’s aphelion, 97.7 AU, not the 80 AU ring', () => {
|
||||||
|
const renderer = new SystemOrbitsRenderer([drawn(67.934, 0.4382)], []);
|
||||||
|
expect(renderer.outermostRadiusAu).toBeCloseTo(67.934 * 1.4382, 9);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reaches an exoplanet’s aphelion too: HD 20782 b’s, 1.66 times its 1.6 AU ring', () => {
|
||||||
|
// The most eccentric of the 303 exoplanet systems with an orbit past their ring, counted on
|
||||||
|
// exoplanets.json (a = 1.3649 AU, e = 0.95).
|
||||||
|
const renderer = new SystemOrbitsRenderer([], [exoplanet({ id: 'HD 20782 b', name: 'HD 20782 b', orbit: { semiMajorAxisAu: 1.3649, eccentricity: 0.95 } })]);
|
||||||
|
expect(renderer.outermostRadiusAu).toBeCloseTo(1.3649 * 1.95, 9);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is the grid’s outer ring where every orbit stays inside it', () => {
|
||||||
|
const renderer = new SystemOrbitsRenderer([drawn(30, 0.01)], []);
|
||||||
|
expect(renderer.outermostRadiusAu).toBe(35);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('photographs', () => {
|
||||||
|
it('puts them on their bodies once loaded, one a frame, so the GPU is not handed every map at once, and in their own colours', () => {
|
||||||
|
// Ids no other test here draws, since the loaded textures are shared through the cache.
|
||||||
|
const ids = ['ganymede', 'callisto'];
|
||||||
|
const records: BodyRecord[] = ids.map((id, index) => ({
|
||||||
|
id, systemStarId: 0, name: id, kind: 'planet', radiusKm: 2500, orbitSource: 'test',
|
||||||
|
orbit: { semiMajorAxisAu: 1 + index, eccentricity: 0, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD },
|
||||||
|
rates: keplerRates(1 + index, GM_SUN_AU3_PER_DAY2)
|
||||||
|
}));
|
||||||
|
const renderer = new SystemOrbitsRenderer(records, []);
|
||||||
|
const materials = (): THREE.MeshStandardMaterial[] => renderer.members.map((member) => (member.marker as THREE.Mesh).material as THREE.MeshStandardMaterial);
|
||||||
|
const maps = (): Array<THREE.Texture | null> => materials().map((material) => material.map);
|
||||||
|
const colours = (): number[] => materials().map((material) => material.color.getHex());
|
||||||
|
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD);
|
||||||
|
expect(maps()).toEqual([null, null]); // not loaded yet: jsdom never loads an image
|
||||||
|
// Until then each is its kind's flat colour, a planet's pale blue.
|
||||||
|
expect(colours()).toEqual([new THREE.Color(0.55, 0.75, 1).getHex(), new THREE.Color(0.55, 0.75, 1).getHex()]);
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
loadCachedTexture(bodyTexturePath(id)!).image = { width: 2, height: 1 };
|
||||||
|
}
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD);
|
||||||
|
expect(maps().filter(Boolean)).toHaveLength(1);
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD);
|
||||||
|
expect(maps()).toEqual(ids.map((id) => loadCachedTexture(bodyTexturePath(id)!)));
|
||||||
|
// The material multiplies its map by its colour: left pale blue, every planet's photograph would
|
||||||
|
// be tinted, Mars's red cut by 45 per cent.
|
||||||
|
expect(colours()).toEqual([0xffffff, 0xffffff]);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markers', () => {
|
||||||
|
it('draws every body on the one sphere, scaled to its radius, and leaves that sphere when a system is left', () => {
|
||||||
|
const records: BodyRecord[] = [2500, 60000].map((radiusKm, index) => ({
|
||||||
|
id: `body-${index}`, systemStarId: 0, name: `Body ${index}`, kind: 'planet', radiusKm, orbitSource: 'test',
|
||||||
|
orbit: { semiMajorAxisAu: 1 + index, eccentricity: 0, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD },
|
||||||
|
rates: keplerRates(1 + index, GM_SUN_AU3_PER_DAY2)
|
||||||
|
}));
|
||||||
|
const renderer = new SystemOrbitsRenderer(records, [exoplanet({ radiusEarth: 1.1 })], undefined, 1);
|
||||||
|
const meshes = renderer.members.map((member) => member.marker as THREE.Mesh);
|
||||||
|
expect(new Set(meshes.map((mesh) => mesh.geometry)).size).toBe(1);
|
||||||
|
[2500, 60000, 1.1 * 6371].forEach((radiusKm, index) => {
|
||||||
|
const sphere = meshes[index].geometry as THREE.SphereGeometry;
|
||||||
|
expect(meshes[index].scale.x * sphere.parameters.radius).toBeCloseTo(bodyMarkerRadiusAu(radiusKm), 12);
|
||||||
|
});
|
||||||
|
const disposed = vi.fn();
|
||||||
|
meshes[0].geometry.addEventListener('dispose', disposed);
|
||||||
|
renderer.dispose();
|
||||||
|
expect(disposed).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('derived surfaces', () => {
|
||||||
|
const maps = (renderer: SystemOrbitsRenderer): Array<THREE.Texture | null> =>
|
||||||
|
renderer.members.map((member) => ((member.marker as THREE.Mesh).material as THREE.MeshStandardMaterial).map);
|
||||||
|
const nextTask = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
const twoPlanets = (): SystemOrbitsRenderer =>
|
||||||
|
new SystemOrbitsRenderer([], [exoplanet({ radiusEarth: 1.1 }), exoplanet({ id: 'TRAPPIST-1 c', name: 'TRAPPIST-1 c', radiusEarth: 1.0 })], undefined, 1);
|
||||||
|
|
||||||
|
it('paints them after the system is built, one a task, so entering a system is not held up, and in their own colours', async () => {
|
||||||
|
const colours = (renderer: SystemOrbitsRenderer): number[] =>
|
||||||
|
renderer.members.map((member) => ((member.marker as THREE.Mesh).material as THREE.MeshStandardMaterial).color.getHex());
|
||||||
|
const renderer = twoPlanets();
|
||||||
|
expect(maps(renderer)).toEqual([null, null]);
|
||||||
|
// Until then each is its kind's flat colour, an exoplanet's magenta.
|
||||||
|
expect(colours(renderer)).toEqual([new THREE.Color(0.85, 0.4, 0.85).getHex(), new THREE.Color(0.85, 0.4, 0.85).getHex()]);
|
||||||
|
await nextTask();
|
||||||
|
expect(maps(renderer).filter(Boolean)).toHaveLength(1);
|
||||||
|
await nextTask();
|
||||||
|
expect(maps(renderer).every(Boolean)).toBe(true);
|
||||||
|
// Left magenta, every derived surface would be multiplied by it, its green cut by 60 per cent.
|
||||||
|
expect(colours(renderer)).toEqual([0xffffff, 0xffffff]);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a body still waiting for its surface without three warning of an undefined map', () => {
|
||||||
|
const warn = vi.spyOn(console, 'warn');
|
||||||
|
twoPlanets().dispose();
|
||||||
|
expect(warn.mock.calls.flat().join(' ')).not.toContain("parameter 'map'");
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paints nothing once the system is left', async () => {
|
||||||
|
const renderer = twoPlanets();
|
||||||
|
renderer.dispose();
|
||||||
|
await nextTask();
|
||||||
|
expect(maps(renderer)).toEqual([null, null]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exoplanet size without a measured radius', () => {
|
||||||
|
const radiusOf = (overrides: Partial<ExoplanetRecord>): number => {
|
||||||
|
const renderer = new SystemOrbitsRenderer([], [exoplanet(overrides)], undefined, 1);
|
||||||
|
return renderer.members[0].marker.userData['radiusAu'];
|
||||||
|
};
|
||||||
|
const EARTH_AU = 6371 / 149597870.7;
|
||||||
|
|
||||||
|
it('draws a giant known only by its mass at about Jupiter’s size, not at an Earth', () => {
|
||||||
|
// 14 Her b: 2 829 Earth masses, no radius. It used to come out the size of the Earth.
|
||||||
|
expect(radiusOf({ radiusEarth: undefined, massEarth: 2829 }) / EARTH_AU).toBeCloseTo(11.2, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a measured radius over any estimate', () => {
|
||||||
|
expect(radiusOf({ radiusEarth: 1.88, massEarth: 2829 }) / EARTH_AU).toBeCloseTo(1.88, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SystemOrbitsRenderer's star light", () => {
|
||||||
|
const lightOf = (renderer: SystemOrbitsRenderer): THREE.PointLight => {
|
||||||
|
let light: THREE.PointLight | undefined;
|
||||||
|
renderer.object.traverse((object) => (light ??= (object as THREE.PointLight).isPointLight ? (object as THREE.PointLight) : undefined));
|
||||||
|
return light!;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("is white at π from the Sun, or from a star with no temperature", () => {
|
||||||
|
for (const renderer of [new SystemOrbitsRenderer([], [], undefined, 1, 5772), new SystemOrbitsRenderer([], [exoplanet()])]) {
|
||||||
|
expect(lightOf(renderer).color.toArray()).toEqual([1, 1, 1]);
|
||||||
|
expect(lightOf(renderer).intensity).toBe(Math.PI);
|
||||||
|
renderer.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lights an M dwarf's planets orange-red, at the same π", () => {
|
||||||
|
const renderer = new SystemOrbitsRenderer([], [exoplanet()], undefined, 5.5e-4, 2566);
|
||||||
|
const [r, g, b] = lightOf(renderer).color.toArray();
|
||||||
|
expect(r).toBe(1);
|
||||||
|
expect(g).toBeLessThan(0.5);
|
||||||
|
expect(b).toBeLessThan(0.15);
|
||||||
|
expect(lightOf(renderer).intensity).toBe(Math.PI);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('solar-system bodies against Horizons', () => {
|
||||||
|
// The records the app ships, read from bodies.json with their IAU rotational elements, and
|
||||||
|
// Horizons' own positions for them (ICRF, AU; heliocentric for the planets, planet-centred for the
|
||||||
|
// moons) at dates across 1950-2100, so the whole path — the ETL's reading of the mean elements,
|
||||||
|
// their rates, the Laplace planes and the scene's frame — is checked against JPL's ephemeris rather
|
||||||
|
// than against itself. A hand copy of the records stood here, and an ETL that dropped Standish's a,
|
||||||
|
// e and i rates or Io's and Europa's backward periapses passed the whole suite on the data it
|
||||||
|
// wrote. Horizons' dates are TDB and the renderer's are the clock's UT, so each is handed over
|
||||||
|
// TT - UT earlier: 69.184 s today, 29 in 1950.
|
||||||
|
const SHIPPED: BodyRecord[] = JSON.parse(readFileSync(`${process.cwd()}/src/assets/data/bodies.json`, 'utf8'));
|
||||||
|
// Mimas and Phobos among them for the terms of their IAU W that are motion along the orbit: the
|
||||||
|
// Mimas-Tethys libration and Phobos's tidal acceleration (see `orbitalTermsOfPrimeMeridian`).
|
||||||
|
const IDS = ['earth', 'jupiter', 'saturn', 'neptune', 'pluto', 'moon', 'io', 'europa', 'titan', 'triton', 'uranus', 'titania', 'charon', 'venus', 'mars', 'mimas', 'phobos'];
|
||||||
|
// Each ceiling sits just above what these elements measure on that date: Earth 0.003 degrees,
|
||||||
|
// Jupiter 0.063, Saturn 0.164, Pluto 0.054, the Moon 0.72 (no mean ellipse has its evection or
|
||||||
|
// variation), Io 0.021, Europa 0.036, Titan 0.014, Triton 0.137, Titania 0.62 (against Uranus's
|
||||||
|
// equator, 120 years from its 1980 epoch), Charon 0.37, Mimas 2.24 on 2026 May 27, when its libration has it
|
||||||
|
// 44 degrees ahead of its mean motion (43.3 without the term), and Phobos 1.25 in 2100 (11.1 without its
|
||||||
|
// tidal acceleration).
|
||||||
|
const HORIZONS: Array<[id: string, jd: number, x: number, y: number, z: number, maxDeg: number]> = [
|
||||||
|
['earth', 2488069.5, -0.1574071329883954, 0.890666220858489, 0.3859132211165683, 0.02],
|
||||||
|
// AD 3000, the end of the clock's window and of Standish's fit: the Earth-Moon barycentre and
|
||||||
|
// Saturn's, 0.005 and 0.065 degrees out. Without Standish's rates for a, e and i they were 0.129
|
||||||
|
// and 0.412, which no date between 1950 and 2100 shows (at most 0.036, Saturn in 2100).
|
||||||
|
['earth', 2816787.5, 0.06574092668156256, 0.9022934196570718, 0.3887693148519465, 0.02],
|
||||||
|
['saturn', 2816787.5, 8.434780522117482, 3.87565654130078, 1.235068259814154, 0.1],
|
||||||
|
['jupiter', 2433282.5, 3.406605247558555, -3.425997624196318, -1.551719750032203, 0.1],
|
||||||
|
['saturn', 2478938.5, -3.51309768447752, -8.723317933082274, -3.452662390556131, 0.25],
|
||||||
|
['pluto', 2442413.5, -29.2488165026956, -7.1421817246801, 6.58403957591589, 0.1],
|
||||||
|
['moon', 2469807.5, 0.00240364781322315, 0.0006554283236619424, 0.0004472719300783614, 2],
|
||||||
|
['io', 2433282.5, 0.0004488349204269952, 0.002519633434577752, 0.00120678715190893, 0.05],
|
||||||
|
['europa', 2433282.5, 0.004084372287322533, -0.001665375585011311, -0.0007673072324795899, 0.1],
|
||||||
|
['titan', 2488069.5, 0.007800850235156121, -0.001556932380983438, -0.0006078959246502567, 0.05],
|
||||||
|
['triton', 2488069.5, -0.001421151845853369, -0.0001894510477241482, 0.001888790702926415, 0.2],
|
||||||
|
['titania', 2488069.5, -0.00151919968294745, -0.0003387914082135071, 0.002465657830788125, 0.75],
|
||||||
|
['charon', 2488069.5, -0.00003046411046017432, -0.000009404114448552256, 0.0001270457155789907, 0.5],
|
||||||
|
['mimas', 2461187.5, -3.840685116962088e-4, 1.182766232573268e-3, -2.006928678577268e-5, 3],
|
||||||
|
['phobos', 2488069.5, 4.269855297288105e-5, -2.759158950396543e-5, -3.816269137755616e-5, 1.5],
|
||||||
|
];
|
||||||
|
|
||||||
|
function record(id: string): BodyRecord {
|
||||||
|
const { kind, orbit, rates, laplacePole, parentBodyId, massRatio, rotationalElements } = SHIPPED.find((body) => body.id === id)!;
|
||||||
|
return { id, systemStarId: 0, name: id, radiusKm: 1000, orbitSource: 'test', kind, orbit, rates, laplacePole, parentBodyId, massRatio, rotationalElements };
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderer = new SystemOrbitsRenderer(IDS.map(record), []);
|
||||||
|
|
||||||
|
for (const [id, jd, x, y, z, maxDeg] of HORIZONS) {
|
||||||
|
it(`puts ${id} within ${maxDeg} degrees of Horizons on JD ${jd}`, () => {
|
||||||
|
renderer.update(utOf(jd));
|
||||||
|
const drawn = renderer.members.find((member) => member.id === id)!.marker.position;
|
||||||
|
const angleDeg = (drawn.angleTo(new THREE.Vector3(x, y, z)) * 180) / Math.PI;
|
||||||
|
expect(angleDeg).toBeLessThan(maxDeg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('puts Pluto where Horizons has it round its barycentre with Charon, 2 131 km out and opposite Charon', () => {
|
||||||
|
// Horizons, Pluto (999) from the Pluto-system barycentre (9), on JD 2488069.5 TDB (2100).
|
||||||
|
const horizons = new THREE.Vector3(0.000003313612032581019, 0.000001023040948538272, -0.00001381793390079716);
|
||||||
|
renderer.update(utOf(2488069.5));
|
||||||
|
const charon = renderer.members.find((member) => member.id === 'charon')!.marker;
|
||||||
|
const barycentre = charon.parent!.position;
|
||||||
|
const pluto = renderer.members.find((member) => member.id === 'pluto')!.marker.position.clone().sub(barycentre);
|
||||||
|
const charonFromBarycentre = charon.position;
|
||||||
|
|
||||||
|
expect((pluto.angleTo(horizons) * 180) / Math.PI).toBeLessThan(0.5);
|
||||||
|
expect(pluto.length() * 149597870.7).toBeCloseTo(horizons.length() * 149597870.7, -1);
|
||||||
|
// Opposite, at the inverse of their mass ratio.
|
||||||
|
expect((pluto.angleTo(charonFromBarycentre) * 180) / Math.PI).toBeCloseTo(180, 6);
|
||||||
|
expect(charonFromBarycentre.length() / pluto.length()).toBeCloseTo(1 / 0.1220485755631374, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws Pluto’s own orbit round the barycentre, in the plane it is going round in', () => {
|
||||||
|
const charon = renderer.members.find((member) => member.id === 'charon')!.marker;
|
||||||
|
const [charonLine, plutoLine] = charon.parent!.children.filter((child) => child.name === 'orbit-line');
|
||||||
|
for (const days of [0, 3000, 30000]) {
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD + days);
|
||||||
|
const pluto = renderer.members.find((member) => member.id === 'pluto')!.marker.position.clone().sub(charon.parent!.position);
|
||||||
|
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(plutoLine.quaternion);
|
||||||
|
expect(Math.abs(pluto.clone().normalize().dot(normal))).toBeLessThan(1e-9);
|
||||||
|
// A near-circle 2 131 km across, a ninth of Charon's.
|
||||||
|
expect(Math.abs(plutoLine.scale.x) * 0.00013095774631236113).toBeCloseTo(pluto.length(), 8);
|
||||||
|
expect(charonLine.scale.x / Math.abs(plutoLine.scale.x)).toBeCloseTo(1 / 0.1220485755631374, 9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns a planet’s drawn orbit with its node, so Mars stays on its own line two thousand years out', () => {
|
||||||
|
// At AD 1 a line fixed at J2000 has Mars 3.3 million km from it, 0.5 million out of its plane.
|
||||||
|
const mars = renderer.members.find((member) => member.id === 'mars')!.marker;
|
||||||
|
const line = renderer.object.children[renderer.object.children.indexOf(mars) - 1];
|
||||||
|
expect(line.name).toBe('orbit-line');
|
||||||
|
for (const days of [0, -730000]) {
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD + days);
|
||||||
|
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(line.quaternion);
|
||||||
|
expect(Math.abs(mars.position.clone().normalize().dot(normal))).toBeLessThan(1e-9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** How far a top-level body is from its own drawn orbit line, in AU: from the nearest of its chords. */
|
||||||
|
function offLineAu(id: string): number {
|
||||||
|
const marker = renderer.members.find((member) => member.id === id)!.marker;
|
||||||
|
const line = renderer.object.children[renderer.object.children.indexOf(marker) - 1] as THREE.Line;
|
||||||
|
expect(line.name).toBe('orbit-line');
|
||||||
|
line.updateMatrixWorld();
|
||||||
|
const position = line.geometry.getAttribute('position');
|
||||||
|
const vertex = (index: number): THREE.Vector3 => new THREE.Vector3().fromBufferAttribute(position, index).applyMatrix4(line.matrixWorld);
|
||||||
|
const chord = new THREE.Line3();
|
||||||
|
const closest = new THREE.Vector3();
|
||||||
|
let nearest = Number.POSITIVE_INFINITY;
|
||||||
|
for (let index = 0; index + 1 < position.count; index++) {
|
||||||
|
nearest = Math.min(nearest, chord.set(vertex(index), vertex(index + 1)).closestPointToPoint(marker.position, true, closest).distanceTo(marker.position));
|
||||||
|
}
|
||||||
|
return nearest;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('redraws a planet’s orbit as its axis and eccentricity drift, so Saturn and Mars stay on their lines at AD 1', () => {
|
||||||
|
// What is left is the 128 chords' own sag from the true ellipse, which depends on where the
|
||||||
|
// planet falls between two points: at most 0.0032 AU for Saturn, near aphelion, and 0.00055 for
|
||||||
|
// Mars. Measured 0.0017 AU for Saturn and 0.0005 for Mars at AD 1, and 0.0011 for Saturn at
|
||||||
|
// J2000. Drawn at J2000's shape at AD 1, the lines were 0.054 AU from Saturn and 0.0022 from Mars.
|
||||||
|
const saturnLine = renderer.object.children[renderer.object.children.indexOf(renderer.members.find((member) => member.id === 'saturn')!.marker) - 1] as THREE.Line;
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD);
|
||||||
|
const drawnVersion = (saturnLine.geometry.getAttribute('position') as THREE.BufferAttribute).version;
|
||||||
|
for (const [id, days, maxAu] of [['saturn', -730000, 0.0035], ['mars', -730000, 0.0006], ['saturn', 0, 0.0035]] as const) {
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD + days);
|
||||||
|
expect(offLineAu(id)).toBeLessThan(maxAu);
|
||||||
|
if (days !== 0) {
|
||||||
|
// Handed to the GPU again, which uploads a buffer only when its version rises: the points
|
||||||
|
// rewritten on the CPU alone leave J2000's ellipse on screen.
|
||||||
|
expect((saturnLine.geometry.getAttribute('position') as THREE.BufferAttribute).version).toBeGreaterThan(drawnVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns the Moon’s drawn orbit with its node, so the Moon stays on its own line', () => {
|
||||||
|
// Half the node's 18.6-year turn on, the ellipse drawn at the epoch has the Moon 10 degrees off
|
||||||
|
// its plane at the worst.
|
||||||
|
const moon = renderer.members.find((member) => member.id === 'moon')!.marker;
|
||||||
|
const line = moon.parent!.children.find((child) => child.name === 'orbit-line')!;
|
||||||
|
for (const days of [0, 1700, 3397, 3400]) {
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD + days);
|
||||||
|
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(line.quaternion);
|
||||||
|
expect(Math.abs(moon.position.clone().normalize().dot(normal))).toBeLessThan(1e-9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const JUNE_1_2025_NOON_UTC = 2460828.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tilt of a body's drawn spin from the orbit it is drawn going round, in degrees: its angular
|
||||||
|
* velocity, read off the sphere a quarter of an hour apart, against its orbit line's normal. Past
|
||||||
|
* 90 is a body turning backwards against its orbit.
|
||||||
|
*/
|
||||||
|
function drawnObliquity(id: string): number {
|
||||||
|
const marker = renderer.members.find((member) => member.id === id)!.marker;
|
||||||
|
const line = renderer.object.children[renderer.object.children.indexOf(marker) - 1];
|
||||||
|
expect(line.name).toBe('orbit-line');
|
||||||
|
renderer.update(JUNE_1_2025_NOON_UTC);
|
||||||
|
const start = marker.quaternion.clone();
|
||||||
|
renderer.update(JUNE_1_2025_NOON_UTC + 0.01);
|
||||||
|
const turn = marker.quaternion.clone().multiply(start.invert());
|
||||||
|
const spin = new THREE.Vector3(turn.x, turn.y, turn.z).multiplyScalar(Math.sign(turn.w));
|
||||||
|
return (spin.angleTo(new THREE.Vector3(0, 0, 1).applyQuaternion(line.quaternion)) * 180) / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('turns Venus, Uranus and Pluto backwards against their orbits, at the tilts Horizons gives the first two', () => {
|
||||||
|
// Pluto's Horizons page gives no tilt; 119.6 is the one its IAU pole makes with its orbit, so for
|
||||||
|
// Pluto this checks that its pole and W are drawn as the kernel gives them, not the pole itself.
|
||||||
|
// The IAU names a planet's north pole by the side of the solar system it lies on, so Venus's W
|
||||||
|
// and Uranus's run backwards; Pluto's pole follows the right-hand rule instead, and points
|
||||||
|
// south. Either way the spin read off the drawn sphere is past 90 degrees from the orbit's pole.
|
||||||
|
expect(drawnObliquity('venus')).toBeCloseTo(177.3, 0);
|
||||||
|
expect(drawnObliquity('uranus')).toBeCloseTo(97.77, 0);
|
||||||
|
expect(drawnObliquity('pluto')).toBeCloseTo(119.6, 0);
|
||||||
|
expect(drawnObliquity('earth')).toBeCloseTo(23.44, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where on its drawn sphere a body faces a point, as east longitude and latitude on its map: read
|
||||||
|
* from the texture coordinates where a ray from that point meets the sphere, so the map's own
|
||||||
|
* convention is part of what is measured.
|
||||||
|
*/
|
||||||
|
function facing(id: string, point: THREE.Vector3): { eastDeg: number; latDeg: number } {
|
||||||
|
const marker = renderer.members.find((member) => member.id === id)!.marker as THREE.Mesh;
|
||||||
|
const centre = worldPosition(id);
|
||||||
|
const towards = point.clone().sub(centre).normalize();
|
||||||
|
const radius = marker.userData['radiusAu'];
|
||||||
|
const hit = new THREE.Raycaster(centre.clone().addScaledVector(towards, radius * 4), towards.clone().negate()).intersectObject(marker)[0];
|
||||||
|
return { eastDeg: (hit.uv!.x - 0.5) * 360, latDeg: (hit.uv!.y - 0.5) * 180 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function worldPosition(id: string): THREE.Vector3 {
|
||||||
|
const marker = renderer.members.find((member) => member.id === id)!.marker;
|
||||||
|
marker.updateWorldMatrix(true, false);
|
||||||
|
return marker.getWorldPosition(new THREE.Vector3());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Degrees between two longitudes, the short way round. */
|
||||||
|
const apart = (a: number, b: number): number => Math.abs(((((a - b) % 360) + 540) % 360) - 180);
|
||||||
|
|
||||||
|
/** Where the IAU puts a body's prime meridian at a TDB date, in the scene. */
|
||||||
|
function iauPrimeMeridian(id: string, jdTdb: number): THREE.Vector3 {
|
||||||
|
const { poleRaDeg, poleDecDeg, primeMeridianDeg } = orientationAt(SHIPPED.find((body) => body.id === id)!.rotationalElements!, jdTdb);
|
||||||
|
const w = (primeMeridianDeg * Math.PI) / 180;
|
||||||
|
const meridian = laplacePlaneToEquatorial({ x: Math.cos(w), y: Math.sin(w), z: 0 }, { raDeg: poleRaDeg, decDeg: poleDecDeg });
|
||||||
|
return new THREE.Vector3(meridian.x, meridian.y, meridian.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The drawn sphere's longitude 0 on its equator: +X of the sphere as `SphereGeometry` wraps its map. */
|
||||||
|
function drawnPrimeMeridian(id: string): THREE.Vector3 {
|
||||||
|
return new THREE.Vector3(1, 0, 0).applyQuaternion(renderer.members.find((member) => member.id === id)!.marker.quaternion);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('turns Jupiter at AD 1000 by its W at that date’s TT, 1 574 s after the UT the clock names', () => {
|
||||||
|
// Espenak and Meeus's ΔT for JD 2086307.5, 1 January 1000 in the Julian calendar, where TT - UT was 23 times what it is today: held
|
||||||
|
// at today's 69 s, Jupiter was drawn 15 degrees short of its W.
|
||||||
|
const jdUt = 2086307.5;
|
||||||
|
renderer.update(jdUt);
|
||||||
|
expect((drawnPrimeMeridian('jupiter').angleTo(iauPrimeMeridian('jupiter', jdUt + 1574.1 / 86400)) * 180) / Math.PI).toBeLessThan(0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns Earth by the UT the clock names, which is its turning: at AD 1000 the Sun stands over Horizons’ point', () => {
|
||||||
|
// Horizons' sub-solar longitude from the Sun (observer quantity 14, TIME_TYPE=UT) on JD 2086455,
|
||||||
|
// 1.0510 E, is Earth as it was 8.454 minutes before. Turned by the IAU's W at UT + 69.184 s, as it
|
||||||
|
// was, the drawn face was 2.3 degrees off (2.0 at UT itself); taken at TDB, which turns it ΔT
|
||||||
|
// (6.6 degrees) further the same way, 8.6.
|
||||||
|
renderer.update(2086455 - 8.45437443 / 1440);
|
||||||
|
expect(apart(facing('earth', new THREE.Vector3()).eastDeg, 1.05101)).toBeLessThan(0.15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lights Earth where the Sun really stands: within 4 degrees of Greenwich at noon UTC', () => {
|
||||||
|
// The equation of time is all that separates them: on 1 June 2025 it puts the Sun over 0.53 W,
|
||||||
|
// and the drawn sphere has it over 0.52 W.
|
||||||
|
renderer.update(JUNE_1_2025_NOON_UTC);
|
||||||
|
expect(Math.abs(facing('earth', new THREE.Vector3()).eastDeg)).toBeLessThan(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Horizons' sub-Earth latitude on Saturn (observer quantity 14, from Earth's centre), which is
|
||||||
|
// planetodetic: taken back to planetocentric through the flattening, it is the angle the rings are
|
||||||
|
// opened to Earth by. Measured: 26.963, 0.075 and -7.764 degrees drawn, against 26.966, 0.042 and
|
||||||
|
// -7.813.
|
||||||
|
const SATURN_FLATTENING = 0.09796;
|
||||||
|
const RING_OPENING: Array<[date: string, jd: number, planetodeticDeg: number]> = [
|
||||||
|
['16 October 2017, near their widest', 2458042.5, 32.017423],
|
||||||
|
['23 March 2025, as Earth crossed their plane', 2460757.5, 0.051359],
|
||||||
|
['24 September 2026, the south face turned to Earth', 2461307.5, -9.571756]
|
||||||
|
];
|
||||||
|
|
||||||
|
const saturnRingMesh = (): THREE.Mesh => renderer.members.find((member) => member.id === 'saturn')!.marker.children[0] as THREE.Mesh;
|
||||||
|
|
||||||
|
/** The ring's face normal in the scene, read off its own geometry rather than its transform. */
|
||||||
|
function ringNormal(ring: THREE.Mesh): THREE.Vector3 {
|
||||||
|
ring.updateWorldMatrix(true, false);
|
||||||
|
return new THREE.Vector3().fromBufferAttribute(ring.geometry.attributes['normal'], 0).transformDirection(ring.matrixWorld);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [date, jd, planetodeticDeg] of RING_OPENING) {
|
||||||
|
it(`opens Saturn's rings to Earth as far as Horizons has them on ${date}`, () => {
|
||||||
|
renderer.update(jd);
|
||||||
|
const normal = ringNormal(saturnRingMesh());
|
||||||
|
const toEarth = worldPosition('earth').sub(worldPosition('saturn')).normalize();
|
||||||
|
const openingDeg = (Math.asin(normal.dot(toEarth)) * 180) / Math.PI;
|
||||||
|
const expectedDeg = (Math.atan((1 - SATURN_FLATTENING) ** 2 * Math.tan((planetodeticDeg * Math.PI) / 180)) * 180) / Math.PI;
|
||||||
|
expect(Math.abs(openingDeg - expectedDeg)).toBeLessThan(0.1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('picks Saturn through its rings', () => {
|
||||||
|
renderer.update(JUNE_1_2025_NOON_UTC);
|
||||||
|
const ring = saturnRingMesh();
|
||||||
|
const normal = ringNormal(ring);
|
||||||
|
const inRingPlane = new THREE.Vector3().fromBufferAttribute(ring.geometry.attributes['position'], 0).transformDirection(ring.matrixWorld);
|
||||||
|
// Straight down onto the B ring, 100 000 km out: nowhere near the planet itself.
|
||||||
|
const onRing = worldPosition('saturn').addScaledVector(inRingPlane, 100000 / 149597870.7);
|
||||||
|
const [hit] = new THREE.Raycaster(onRing.clone().addScaledVector(normal, 0.01), normal.clone().negate()).intersectObjects(renderer.pickableObjects);
|
||||||
|
expect(hit.object).toBe(ring);
|
||||||
|
expect(renderer.memberForObject(hit.object)?.id).toBe('saturn');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Horizons' observer quantities 14 and 15 at 2025-06-01 12:00 UTC, from Earth's centre (from the
|
||||||
|
// Sun's, for Earth): the sub-observer and sub-solar longitude and latitude, east-positive for
|
||||||
|
// Earth and the Moon and west-positive for Mars and Jupiter, as each is printed. Horizons gives
|
||||||
|
// each body as it was when the light now arriving left it, so it is drawn that much earlier. Its
|
||||||
|
// latitudes are planetodetic, on the body's flattened figure, which a sphere does not have, so the
|
||||||
|
// drawn latitude is put on that figure before they are compared: without it they differ by what
|
||||||
|
// the flattening makes of them, 0.14 degrees on Earth, 0.26 on Mars and 0.33 on Jupiter.
|
||||||
|
//
|
||||||
|
// Measured: every longitude within 0.09 degrees and every latitude within 0.03, but for the
|
||||||
|
// Moon's face towards Earth, 0.70 and 0.09 out because its mean orbit is (its evection alone is
|
||||||
|
// 1.27 degrees); its face towards the Sun is within 0.002. Io's face towards Jupiter is 0.012 out:
|
||||||
|
// with its orbit taken at the clock's UTC and its spin at TDB it was 0.175, the 69 s between them.
|
||||||
|
const SUB_POINTS: Array<[id: string, observer: string | undefined, lightMinutes: number, west: boolean, flattening: number, observerLon: number, observerLat: number, sunLon: number, sunLat: number, maxObserverDeg: number]> = [
|
||||||
|
['earth', undefined, 8.43351424, false, 1 / 298.257, 1.5855, 22.261204, 1.579501, 22.260426, 0.1],
|
||||||
|
['mars', 'earth', 14.13295841, true, 1 - 3376.2 / 3396.19, 307.365389, 21.27653, 269.287887, 25.451264, 0.1],
|
||||||
|
['moon', 'earth', 0.02150549, false, 0, 7.256763, -3.462104, 116.285934, 1.503004, 0.8],
|
||||||
|
['jupiter', 'earth', 50.70337676, true, 1 - 66854 / 71492, 251.139846, 2.58787, 247.855871, 2.572658, 0.1],
|
||||||
|
['io', 'jupiter', 0.02340584, true, 0, 359.964094, -0.002537, 355.673108, 2.26528, 0.05]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [id, observer, lightMinutes, west, flattening, observerLon, observerLat, sunLon, sunLat, maxObserverDeg] of SUB_POINTS) {
|
||||||
|
it(`faces ${observer ?? 'the Sun'} and the Sun with the points Horizons gives on ${id}`, () => {
|
||||||
|
renderer.update(JUNE_1_2025_NOON_UTC - lightMinutes / 1440);
|
||||||
|
const seen = facing(id, observer ? worldPosition(observer) : new THREE.Vector3());
|
||||||
|
const lit = facing(id, new THREE.Vector3());
|
||||||
|
const east = (longitude: number): number => (west ? -longitude : longitude);
|
||||||
|
const planetodetic = (latDeg: number): number => (Math.atan(Math.tan((latDeg * Math.PI) / 180) / (1 - flattening) ** 2) * 180) / Math.PI;
|
||||||
|
expect(apart(seen.eastDeg, east(observerLon))).toBeLessThan(maxObserverDeg);
|
||||||
|
expect(Math.abs(planetodetic(seen.latDeg) - observerLat)).toBeLessThan(maxObserverDeg);
|
||||||
|
expect(apart(lit.eastDeg, east(sunLon))).toBeLessThan(0.1);
|
||||||
|
expect(Math.abs(planetodetic(lit.latDeg) - sunLat)).toBeLessThan(0.05);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('locked moons across the clock’s window', () => {
|
||||||
|
// As shipped, pole, W and all: the IAU gives each a W fitted near the present, and its rate is
|
||||||
|
// not quite its orbit's, nor Iapetus's pole a line for twenty centuries.
|
||||||
|
const shipped: BodyRecord[] = JSON.parse(readFileSync(`${process.cwd()}/src/assets/data/bodies.json`, 'utf8'));
|
||||||
|
const renderer = new SystemOrbitsRenderer(
|
||||||
|
shipped.filter((body) => ['jupiter', 'saturn', 'uranus', 'neptune', 'europa', 'ganymede', 'callisto', 'mimas', 'rhea', 'iapetus', 'miranda', 'triton', 'proteus'].includes(body.id)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Degrees between two lines, the way a spin axis and an orbit normal are compared: Miranda turns backwards against the IAU's pole. */
|
||||||
|
function linesApartDeg(a: THREE.Vector3, b: THREE.Vector3): number {
|
||||||
|
return (Math.acos(Math.min(1, Math.abs(a.clone().normalize().dot(b.clone().normalize())))) * 180) / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A moon's drawn spin axis and the normal of its drawn orbit line, in the scene's ICRF frame. */
|
||||||
|
function axisAndOrbitNormal(id: string): { axis: THREE.Vector3; normal: THREE.Vector3 } {
|
||||||
|
const moon = renderer.members.find((member) => member.id === id)!.marker;
|
||||||
|
const line = moon.parent!.children.find((child) => child.name === 'orbit-line')!;
|
||||||
|
return { axis: new THREE.Vector3(0, 1, 0).applyQuaternion(moon.quaternion), normal: new THREE.Vector3(0, 0, 1).applyQuaternion(line.quaternion) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** East longitude, on its map, of the point on a moon's drawn sphere that faces its planet. */
|
||||||
|
function facingPlanet(id: string): number {
|
||||||
|
const moon = renderer.members.find((member) => member.id === id)!.marker;
|
||||||
|
// Its position is from the planet, which is its pivot; SphereGeometry wraps u = atan2(z, -x) / 2 pi.
|
||||||
|
const toPlanet = moon.position.clone().negate().applyQuaternion(moon.quaternion.clone().invert());
|
||||||
|
const u = Math.atan2(toPlanet.z, -toPlanet.x) / (2 * Math.PI);
|
||||||
|
return ((((u - 0.5) * 360) % 360) + 540) % 360 - 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps Proteus, Miranda, Mimas and Iapetus facing their planets at AD 1 and AD 3000', () => {
|
||||||
|
// Measured: Proteus 2.6 degrees at most over AD 1-3000, Miranda 2.4, Mimas 8.9, Iapetus 16 (9.4
|
||||||
|
// of it the lag of the row its orbit is drawn from). On the IAU's own W and Iapetus's straight
|
||||||
|
// pole they were 146, 23, 49 and 87 degrees at AD 1.
|
||||||
|
for (const jd of [1721425.5, 2816787.4]) {
|
||||||
|
renderer.update(jd);
|
||||||
|
expect(Math.abs(facingPlanet('proteus'))).toBeLessThan(3);
|
||||||
|
expect(Math.abs(facingPlanet('miranda'))).toBeLessThan(3);
|
||||||
|
expect(Math.abs(facingPlanet('mimas'))).toBeLessThan(9.5);
|
||||||
|
expect(Math.abs(facingPlanet('iapetus'))).toBeLessThan(16.5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the axes of Mimas and Iapetus on their drawn orbits’ normals, as a Cassini state holds them, at AD 1, today and AD 3000', () => {
|
||||||
|
// Measured over AD 1-3000: Mimas 0.44 degrees at most, Iapetus 0.74. With Iapetus's pole on
|
||||||
|
// its Laplace pole, 8.3 off at every date.
|
||||||
|
for (const jd of [1721425.5, 2460676.5, 2816787.4]) {
|
||||||
|
renderer.update(jd);
|
||||||
|
for (const id of ['mimas', 'iapetus']) {
|
||||||
|
const { axis, normal } = axisAndOrbitNormal(id);
|
||||||
|
expect(linesApartDeg(axis, normal)).toBeLessThan(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns the poles of Europa, Ganymede, Callisto, Rhea, Miranda and Triton round with their drawn nodes, at AD 1, today and AD 3000', () => {
|
||||||
|
// Each pole goes round on a term of its node's angle, re-rated to the node's drawn rate (see
|
||||||
|
// `lockedToOrbit`), each node at JPL's current rate. Measured at these dates: at most 0.23
|
||||||
|
// degrees (Miranda). On the IAU's rates Rhea is 0.73, Miranda 0.51 and Triton 0.42, and on the
|
||||||
|
// archived table's node periods Callisto 0.48 and Miranda 0.42.
|
||||||
|
for (const jd of [1721425.5, 2460676.5, 2816787.4]) {
|
||||||
|
renderer.update(jd);
|
||||||
|
for (const id of ['europa', 'ganymede', 'callisto', 'rhea', 'miranda', 'triton']) {
|
||||||
|
const { axis, normal } = axisAndOrbitNormal(id);
|
||||||
|
expect(linesApartDeg(axis, normal), id).toBeLessThan(0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws Miranda’s orbit, and turns its axis, where Horizons has its orbit in 1601 and 2390', () => {
|
||||||
|
// Horizons' osculating orbit normal (ura184, ICRF), averaged over three of Miranda's orbits about
|
||||||
|
// each date; it wobbles 0.01 degrees about that. The drawn node turns at JPL's current 17.787-year
|
||||||
|
// period (see `nodePeriodYears` in the ETL); on the archived table's 17.727, which the IAU's pole
|
||||||
|
// was once turned after too, the drawn orbit was 2.1 degrees from Horizons' at both dates and the
|
||||||
|
// axis 2.4 at 1601. A date this far back is TDB less some two minutes; the node moves 0.004 degrees in that.
|
||||||
|
const HORIZONS_NORMALS: Array<[jd: number, raDeg: number, decDeg: number]> = [
|
||||||
|
[2305813.5, 72.83137, 16.17526],
|
||||||
|
[2594102.5, 81.27691, 17.43782]
|
||||||
|
];
|
||||||
|
for (const [jd, raDeg, decDeg] of HORIZONS_NORMALS) {
|
||||||
|
renderer.update(jd);
|
||||||
|
const [ra, dec] = [(raDeg * Math.PI) / 180, (decDeg * Math.PI) / 180];
|
||||||
|
const horizons = new THREE.Vector3(Math.cos(dec) * Math.cos(ra), Math.cos(dec) * Math.sin(ra), Math.sin(dec));
|
||||||
|
const { axis, normal } = axisAndOrbitNormal('miranda');
|
||||||
|
expect(linesApartDeg(normal, horizons)).toBeLessThan(0.5);
|
||||||
|
expect(linesApartDeg(axis, horizons)).toBeLessThan(0.5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import * as THREE from 'three/webgpu';
|
import * as THREE from 'three/webgpu';
|
||||||
|
|
||||||
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
||||||
import { gmForParent } from '../../shared/astro/constants';
|
|
||||||
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
|
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
|
||||||
import { MARKER_TEXTURE_HEIGHT, MARKER_TEXTURE_WIDTH, planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
||||||
import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
|
import { bodyTexturePath, loadCachedTexture, saturnRing } from '../../shared/rendering/texture-catalog';
|
||||||
|
import { isPropagatableOrbit, keplerRates, meanElementsAt, orbitEllipsePoints, positionAtEpoch, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
|
||||||
import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
||||||
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
|
import { tdbFromUtc } from '../../shared/astro/constants';
|
||||||
|
import { blackbodyColor, SOLAR_EFFECTIVE_TEMPERATURE_K } from '../../shared/astro/stellar';
|
||||||
|
import { BodyRecord, MeanElementRates, OrbitalElements, RotationalElements } from '../../shared/models/body.model';
|
||||||
|
import { bodyOrientation, poleFrame } from '../../shared/rendering/body-orientation';
|
||||||
import { bodyMarkerRadiusAu, systemGridRingsAu } from './system-framing';
|
import { bodyMarkerRadiusAu, systemGridRingsAu } from './system-framing';
|
||||||
import { PolarGridPlane, TetherField } from './grid-plane';
|
import { PolarGridPlane, TetherField } from './grid-plane';
|
||||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||||
@@ -18,6 +21,8 @@ export interface SystemMember {
|
|||||||
id: string;
|
id: string;
|
||||||
kind: SystemMemberKind;
|
kind: SystemMemberKind;
|
||||||
marker: THREE.Object3D;
|
marker: THREE.Object3D;
|
||||||
|
/** For a moon, the id of the body it orbits: what its drawn size is held against. */
|
||||||
|
parentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLANET_COLOR = new THREE.Color(0.55, 0.75, 1.0);
|
const PLANET_COLOR = new THREE.Color(0.55, 0.75, 1.0);
|
||||||
@@ -42,11 +47,38 @@ const SYSTEM_TETHER_OPACITY = 0.3;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Rotation carrying the **ecliptic** frame into the scene's equatorial one — a turn of the
|
* Rotation carrying the **ecliptic** frame into the scene's equatorial one — a turn of the
|
||||||
* obliquity about the shared vernal-equinox axis. Solar-system elements come from Horizons
|
* obliquity about the shared vernal-equinox axis. The planets' and the Moon's mean elements are
|
||||||
* against the ecliptic, so this is their frame.
|
* given against the J2000 ecliptic, so this is their frame.
|
||||||
*/
|
*/
|
||||||
const ECLIPTIC_FRAME = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), OBLIQUITY_J2000_DEG * DEG_TO_RAD);
|
const ECLIPTIC_FRAME = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), OBLIQUITY_J2000_DEG * DEG_TO_RAD);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotation carrying a moon's element frame into the scene: its local Laplace plane where JPL
|
||||||
|
* gives one, the ecliptic otherwise. {@link poleFrame} builds it from the axes
|
||||||
|
* `laplacePlaneToEquatorial` sends, so the scene and the ETL's check against Horizons share the
|
||||||
|
* one conversion.
|
||||||
|
*/
|
||||||
|
function moonFrame(body: BodyRecord): THREE.Quaternion {
|
||||||
|
return body.laplacePole ? poleFrame(body.laplacePole) : ECLIPTIC_FRAME.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
const X_AXIS = new THREE.Vector3(1, 0, 0);
|
||||||
|
const Z_AXIS = new THREE.Vector3(0, 0, 1);
|
||||||
|
const scratchTurn = new THREE.Quaternion();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets `target` to the rotation carrying an orbit's own plane, periapsis along +X, into the
|
||||||
|
* scene: the argument of periapsis, then the inclination, then the node, as
|
||||||
|
* `positionAtTrueAnomaly` turns a point, and then the frame the elements are measured in.
|
||||||
|
*/
|
||||||
|
function orientOrbit(target: THREE.Quaternion, elements: OrbitalElements, frame: THREE.Quaternion): THREE.Quaternion {
|
||||||
|
return target
|
||||||
|
.copy(frame)
|
||||||
|
.multiply(scratchTurn.setFromAxisAngle(Z_AXIS, elements.longitudeOfAscendingNodeDeg * DEG_TO_RAD))
|
||||||
|
.multiply(scratchTurn.setFromAxisAngle(X_AXIS, elements.inclinationDeg * DEG_TO_RAD))
|
||||||
|
.multiply(scratchTurn.setFromAxisAngle(Z_AXIS, elements.argumentOfPeriapsisDeg * DEG_TO_RAD));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rotation carrying the frame an **exoplanet's** elements are measured in into the scene.
|
* Rotation carrying the frame an **exoplanet's** elements are measured in into the scene.
|
||||||
*
|
*
|
||||||
@@ -91,21 +123,18 @@ function colorForKind(kind: SystemMemberKind): THREE.Color {
|
|||||||
/** Marks orbit lines so the whole layer can be toggled without touching the bodies. */
|
/** Marks orbit lines so the whole layer can be toggled without touching the bodies. */
|
||||||
const ORBIT_LINE_NAME = 'orbit-line';
|
const ORBIT_LINE_NAME = 'orbit-line';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The orbit's ellipse, drawn in its own plane and turned into place by the line's quaternion (see
|
||||||
|
* {@link orientOrbit}), which `update` sets again each tick: a node and a periapsis that turn cost
|
||||||
|
* a quaternion rather than a new geometry. The Moon's node goes right round in 18.6 years, so an
|
||||||
|
* ellipse fixed at one date has the Moon up to 2 sin 5.16° of its distance, 69 000 km, off its own
|
||||||
|
* line nine years on.
|
||||||
|
*
|
||||||
|
* The shape is redrawn by {@link reshapeOrbitLine} as the planets' axes and eccentricities drift.
|
||||||
|
*/
|
||||||
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame: THREE.Quaternion): THREE.Line {
|
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) => {
|
|
||||||
// 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();
|
const geometry = new THREE.BufferGeometry();
|
||||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
geometry.setAttribute('position', new THREE.BufferAttribute(ellipseInItsPlane(elements, new Float32Array((ORBIT_LINE_SEGMENTS + 1) * 3)), 3));
|
||||||
|
|
||||||
const material = new THREE.LineBasicMaterial({
|
const material = new THREE.LineBasicMaterial({
|
||||||
color: colorForKind(kind),
|
color: colorForKind(kind),
|
||||||
@@ -115,45 +144,220 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame
|
|||||||
|
|
||||||
const line = new THREE.Line(geometry, material);
|
const line = new THREE.Line(geometry, material);
|
||||||
line.name = ORBIT_LINE_NAME;
|
line.name = ORBIT_LINE_NAME;
|
||||||
|
line.userData = { semiMajorAxisAu: elements.semiMajorAxisAu, eccentricity: elements.eccentricity };
|
||||||
|
orientOrbit(line.quaternion, elements, frame);
|
||||||
return line;
|
return line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ORBIT_LINE_SEGMENTS = 128;
|
||||||
|
|
||||||
|
/** The orbit's ellipse in its own plane, periapsis along +X, written into `positions`. */
|
||||||
|
function ellipseInItsPlane(elements: OrbitalElements, positions: Float32Array): Float32Array {
|
||||||
|
orbitEllipsePoints({ ...elements, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0 }, ORBIT_LINE_SEGMENTS).forEach((point, index) => {
|
||||||
|
positions[index * 3] = point.x;
|
||||||
|
positions[index * 3 + 1] = point.y;
|
||||||
|
positions[index * 3 + 2] = point.z;
|
||||||
|
});
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A marker sphere, surfaced with the body's own derived appearance rather than a flat category
|
* How far, in AU, an orbit's drawn ellipse may be from its current one before it is drawn again:
|
||||||
* colour — so a system reads as a set of distinct worlds at a glance, and the colour of each is
|
* well under the 128 chords' own sag from the true curve, at most 0.00055 AU for Mars and 0.0032
|
||||||
* a consequence of its measurements rather than of which list it came from.
|
* for Saturn, near aphelion, where points spaced evenly in true anomaly lie furthest apart.
|
||||||
*
|
|
||||||
* The texture is tiny (see `MARKER_TEXTURE_WIDTH`): a marker is a few pixels across, so what
|
|
||||||
* survives is essentially its average colour, and generating it costs well under a millisecond.
|
|
||||||
*/
|
*/
|
||||||
function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined, systemSpanAu: number, appearance: PlanetAppearance | undefined): THREE.Mesh {
|
const ORBIT_RESHAPE_AU = 1e-4;
|
||||||
const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm, systemSpanAu), 16, 12);
|
|
||||||
const material = appearance
|
/**
|
||||||
? new THREE.MeshBasicMaterial({ map: planetTexture(appearance, { width: MARKER_TEXTURE_WIDTH, height: MARKER_TEXTURE_HEIGHT }) })
|
* Draws an orbit line's ellipse again once the axis and eccentricity it was drawn with have drifted
|
||||||
: new THREE.MeshBasicMaterial({ color: colorForKind(kind) });
|
* from `elements`' by more than {@link ORBIT_RESHAPE_AU}. Standish's rates move Saturn's
|
||||||
return new THREE.Mesh(geometry, material);
|
* eccentricity 0.0064 in twenty centuries, and left at J2000's, the line passed 0.056 AU, 8.4
|
||||||
|
* million km, from Saturn at AD 1; Jupiter 0.016 AU there, Pluto 0.021 at AD 3000. The moons' and
|
||||||
|
* the exoplanets' elements carry no such rates, so their lines are drawn once.
|
||||||
|
*/
|
||||||
|
function reshapeOrbitLine(line: THREE.Line, elements: OrbitalElements): void {
|
||||||
|
const drawn = line.userData as { semiMajorAxisAu: number; eccentricity: number };
|
||||||
|
const driftAu = Math.abs(elements.semiMajorAxisAu - drawn.semiMajorAxisAu) + elements.semiMajorAxisAu * Math.abs(elements.eccentricity - drawn.eccentricity);
|
||||||
|
if (driftAu <= ORBIT_RESHAPE_AU) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const position = line.geometry.getAttribute('position') as THREE.BufferAttribute;
|
||||||
|
ellipseInItsPlane(elements, position.array as Float32Array);
|
||||||
|
position.needsUpdate = true;
|
||||||
|
line.geometry.computeBoundingSphere();
|
||||||
|
line.userData = { semiMajorAxisAu: elements.semiMajorAxisAu, eccentricity: elements.eccentricity };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A marker sphere, surfaced with the body's own photograph where one has ever been taken, and
|
||||||
|
* with a texture derived from its measurements where none has — and lit by its star either way,
|
||||||
|
* so a world shows the day and night it actually has.
|
||||||
|
*
|
||||||
|
* The photographs were already in the repository, used only by the detail page: the system view
|
||||||
|
* drew every body from a 32 by 16 pixel procedural texture instead, which at a few pixels across
|
||||||
|
* was indistinguishable from its average colour and, once the camera closed in, was a blur. A
|
||||||
|
* marker can now fill the frame, so it takes the real image at the size the detail page uses.
|
||||||
|
*
|
||||||
|
* A derived texture is not painted here but handed to `deferSurface`, which paints it after the
|
||||||
|
* system is built: at about 4.4 ms each, the twenty bodies the solar system gained with its dwarf
|
||||||
|
* planets and smaller moons lengthened the task that enters it from 78-94 ms to 177-228. Until
|
||||||
|
* then the body is its kind's flat colour.
|
||||||
|
*
|
||||||
|
* A photograph is handed to `deferPhotograph`, which puts it on the body once it has loaded, one a
|
||||||
|
* frame: a texture is copied to the GPU in the first frame that draws it, and the 28 maps, which
|
||||||
|
* arrive within 40 ms of each other, made that one frame a 160-210 ms task on entering the Sun's
|
||||||
|
* system (copyExternalImageToTexture, about 38 megapixels of JPEG: nine maps at 2048 by 1024, the
|
||||||
|
* Sun's among them, Jupiter's at 3840 by 1920, and eighteen smaller).
|
||||||
|
*
|
||||||
|
* Every marker is the one unit sphere, {@link MARKER_SPHERE}, scaled to the body's radius, which
|
||||||
|
* it also keeps as `userData.radiusAu`: built one a body, the 38 spheres of the Sun's system took
|
||||||
|
* 12 ms of the 15 ms the renderer took to build and, with their upload, made a return to the
|
||||||
|
* system a long task of 52 to 70 ms, where the 18 bodies before had made none.
|
||||||
|
*/
|
||||||
|
function buildMarker(
|
||||||
|
id: string | undefined,
|
||||||
|
kind: SystemMemberKind,
|
||||||
|
radiusKm: number | undefined,
|
||||||
|
appearance: PlanetAppearance | undefined,
|
||||||
|
deferSurface: (paint: () => void) => void,
|
||||||
|
deferPhotograph: (material: THREE.MeshStandardMaterial, texture: THREE.Texture) => void
|
||||||
|
): THREE.Mesh {
|
||||||
|
const photograph = id ? bodyTexturePath(id) : undefined;
|
||||||
|
// null, not undefined, until there is one: three warns "parameter 'map' has value of
|
||||||
|
// undefined" for every body built so, eleven of them on entering the Sun's system.
|
||||||
|
const material = new THREE.MeshStandardMaterial({
|
||||||
|
map: null,
|
||||||
|
color: colorForKind(kind),
|
||||||
|
roughness: 1,
|
||||||
|
metalness: 0
|
||||||
|
});
|
||||||
|
if (photograph) {
|
||||||
|
deferPhotograph(material, loadCachedTexture(photograph));
|
||||||
|
} else if (appearance) {
|
||||||
|
deferSurface(() => {
|
||||||
|
// 128 by 64, not the detail page's 512 by 256: that size costs about 60 ms a body on the
|
||||||
|
// main thread, for a disc that is a few pixels across until the camera is on top of it.
|
||||||
|
material.map = planetTexture(appearance, { width: 128, height: 64 });
|
||||||
|
material.color.set(0xffffff);
|
||||||
|
material.needsUpdate = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const marker = new THREE.Mesh(MARKER_SPHERE, material);
|
||||||
|
const radiusAu = bodyMarkerRadiusAu(radiusKm);
|
||||||
|
marker.scale.setScalar(radiusAu);
|
||||||
|
marker.userData = { radiusAu };
|
||||||
|
return marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The star's own light, at the centre of the system it lights.
|
||||||
|
*
|
||||||
|
* `decay` is 0, which is not what light does: a point source falls off with the square of the
|
||||||
|
* distance, and under that law Neptune, at 30.2 AU, receives about a six-thousandth of what
|
||||||
|
* Mercury does at 0.39 AU and reads as black. The map is a set of worlds to look at rather than a
|
||||||
|
* light meter, so each is lit as a photograph of it would be — the same concession the pixel
|
||||||
|
* floor makes for size. What the light does carry truthfully is which side is day: every body
|
||||||
|
* shows its lit face toward the star, and the terminator falls where it really falls.
|
||||||
|
*
|
||||||
|
* At π: a Lambertian surface returns intensity / π of its texture where the light falls square on
|
||||||
|
* it, so π gives back the photograph itself at the point facing the star, and less towards the
|
||||||
|
* limb. A smaller figure darkened the photographs below what they are.
|
||||||
|
*
|
||||||
|
* In the star's own colour, against the Sun's: the photographs were taken in sunlight, so the
|
||||||
|
* Sun's light is white and gives them back as they are, and another star's shifts them as its
|
||||||
|
* spectrum differs from the Sun's — a 2 566 K M dwarf's is (1, 0.44, 0.10), orange-red, and a
|
||||||
|
* 9 600 K A star's (0.52, 0.67, 1), blue. A star with no temperature is lit as the Sun.
|
||||||
|
*/
|
||||||
|
function starLight(temperatureK: number | null | undefined): THREE.PointLight {
|
||||||
|
const light = new THREE.PointLight(0xffffff, Math.PI, 0, 0);
|
||||||
|
light.color.setRGB(...blackbodyColor(temperatureK ?? SOLAR_EFFECTIVE_TEMPERATURE_K, SOLAR_EFFECTIVE_TEMPERATURE_K), THREE.LinearSRGBColorSpace);
|
||||||
|
light.position.set(0, 0, 0);
|
||||||
|
return light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sphere segments. On a UV sphere the silhouette seen down the pole is the ring of width segments
|
||||||
|
* and the one seen from the side is the meridian profile, so height at half the width makes the
|
||||||
|
* error the same from every direction: at 64 by 32 a body filling the screen — Jupiter reaches
|
||||||
|
* 641 px of radius in the plan view — strays under a pixel from its true circle.
|
||||||
|
*/
|
||||||
|
const MARKER_WIDTH_SEGMENTS = 64;
|
||||||
|
const MARKER_HEIGHT_SEGMENTS = 32;
|
||||||
|
/** Shared by every marker of every system, so it is never disposed; see `buildMarker`. */
|
||||||
|
const MARKER_SPHERE = new THREE.SphereGeometry(1, MARKER_WIDTH_SEGMENTS, MARKER_HEIGHT_SEGMENTS);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A drawn radius, in Earth radii, for an exoplanet that has a mass and no measured radius — 1 076
|
||||||
|
* of the 1 692 drawn, most of them found by radial velocity, and most of those giants: their
|
||||||
|
* median is 315 Earth masses. Drawn at an Earth, as they were, a nine-Jupiter-mass planet came out
|
||||||
|
* smaller than its system's super-Earth.
|
||||||
|
*
|
||||||
|
* A rough power law, capped at Jupiter's radius: giants from a third of a Jupiter mass to ten are
|
||||||
|
* all about Jupiter's size, since past that point added mass compresses rather than inflates. It
|
||||||
|
* sets a size to draw, not a figure to print — the readout still says the radius is unknown.
|
||||||
|
*/
|
||||||
|
function radiusFromMassEarth(massEarth: number | null | undefined): number | undefined {
|
||||||
|
return massEarth && massEarth > 0 ? Math.min(JUPITER_RADIUS_EARTH, massEarth ** 0.55) : undefined;
|
||||||
|
}
|
||||||
|
const JUPITER_RADIUS_EARTH = 11.2;
|
||||||
|
|
||||||
|
/** Local axis a sphere is built around, and what the spin is applied about. */
|
||||||
|
const SPIN_AXIS = new THREE.Vector3(0, 1, 0);
|
||||||
|
const HOURS_PER_DAY = 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a body the IAU gives no rotational elements for is turned at a given date — Eris, Haumea,
|
||||||
|
* Makemake and Nereid, whose periods are measured (Makemake's only to a factor of two, see its
|
||||||
|
* spec in `fetchSolarSystem.ts`) and whose poles are not: at its own sidereal rate, about
|
||||||
|
* its orbit's normal, backwards for a negative period. None of them has an obliquity, so none is
|
||||||
|
* applied. The phase is arbitrary: each body starts at its elements' epoch in the shortest
|
||||||
|
* rotation of +Y onto its axis, and turns from there. Exoplanets have no published rotation at
|
||||||
|
* all, and are left still.
|
||||||
|
*
|
||||||
|
* Every other body is turned by {@link bodyOrientation}.
|
||||||
|
*/
|
||||||
|
function spinFor(elements: OrbitalElements, frame: THREE.Quaternion, rotationPeriodHours: number, daysSinceEpoch: number): THREE.Quaternion {
|
||||||
|
const node = elements.longitudeOfAscendingNodeDeg * DEG_TO_RAD;
|
||||||
|
const inclination = elements.inclinationDeg * DEG_TO_RAD;
|
||||||
|
const axis = new THREE.Vector3(Math.sin(inclination) * Math.sin(node), -Math.sin(inclination) * Math.cos(node), Math.cos(inclination)).applyQuaternion(frame);
|
||||||
|
const turns = (daysSinceEpoch * HOURS_PER_DAY) / rotationPeriodHours;
|
||||||
|
return new THREE.Quaternion()
|
||||||
|
.setFromUnitVectors(SPIN_AXIS, axis)
|
||||||
|
.multiply(new THREE.Quaternion().setFromAxisAngle(SPIN_AXIS, turns * 2 * Math.PI));
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrackedTopLevelBody {
|
interface TrackedTopLevelBody {
|
||||||
id: string;
|
id: string;
|
||||||
kind: SystemMemberKind;
|
kind: SystemMemberKind;
|
||||||
elements: OrbitalElements;
|
elements: OrbitalElements;
|
||||||
gmAu3PerDay2: number;
|
rates: MeanElementRates;
|
||||||
marker: THREE.Mesh;
|
marker: THREE.Mesh;
|
||||||
|
orbitLine: THREE.Line;
|
||||||
/** Rotation from this body's own element frame into the scene's equatorial one. */
|
/** Rotation from this body's own element frame into the scene's equatorial one. */
|
||||||
frame: THREE.Quaternion;
|
frame: THREE.Quaternion;
|
||||||
/** AU position last computed for this body; moons read their parent's here. */
|
/** AU position last computed for this body; moons read their parent's here. */
|
||||||
position: THREE.Vector3;
|
position: THREE.Vector3;
|
||||||
|
/** Sidereal rotation, where the catalogue publishes one; negative is retrograde. */
|
||||||
|
rotationPeriodHours?: number;
|
||||||
|
rotationalElements?: RotationalElements;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrackedMoon {
|
interface TrackedMoon {
|
||||||
id: string;
|
id: string;
|
||||||
elements: OrbitalElements;
|
elements: OrbitalElements;
|
||||||
gmAu3PerDay2: number;
|
rates: MeanElementRates;
|
||||||
marker: THREE.Mesh;
|
marker: THREE.Mesh;
|
||||||
|
orbitLine: THREE.Line;
|
||||||
frame: THREE.Quaternion;
|
frame: THREE.Quaternion;
|
||||||
pivot: THREE.Group;
|
pivot: THREE.Group;
|
||||||
parentId: string;
|
parentId: string;
|
||||||
|
rotationPeriodHours?: number;
|
||||||
|
rotationalElements?: RotationalElements;
|
||||||
|
/**
|
||||||
|
* Where the moon and its planet go round a barycentre outside the planet (Charon): the moon's
|
||||||
|
* mass over the planet's, and the planet's own small orbit round that point.
|
||||||
|
*/
|
||||||
|
barycentre?: { massRatio: number; parentOrbitLine: THREE.Line };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,18 +371,18 @@ export class SystemOrbitsRenderer {
|
|||||||
readonly members: readonly SystemMember[];
|
readonly members: readonly SystemMember[];
|
||||||
/** Largest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
|
/** Largest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
|
||||||
readonly maxTopLevelSemiMajorAxisAu: number;
|
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
|
* 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.
|
* frame: the ecliptic for the solar system, the plane of the sky for everything else.
|
||||||
*/
|
*/
|
||||||
readonly referenceFrame: THREE.Quaternion;
|
readonly referenceFrame: THREE.Quaternion;
|
||||||
/**
|
/**
|
||||||
* Outer radius (AU) of the reference grid, or 0 where there is none. This — not the outermost
|
* How far (AU) from the star the system draws anything, or 0 where it draws nothing: what the
|
||||||
* orbit — is the widest thing the system draws, so it is what the camera has to frame.
|
* camera has to frame. The reference grid's outer ring, which runs 15 per cent past the largest
|
||||||
|
* semi-major axis, unless an eccentric orbit reaches further at its aphelion — Eris's, 97.7 AU,
|
||||||
|
* does past the solar system's 80 AU ring, and some orbit does in 303 of the 1 190 exoplanet systems.
|
||||||
*/
|
*/
|
||||||
readonly gridOuterRadiusAu: number;
|
readonly outermostRadiusAu: number;
|
||||||
|
|
||||||
private readonly topLevelBodies: TrackedTopLevelBody[] = [];
|
private readonly topLevelBodies: TrackedTopLevelBody[] = [];
|
||||||
private readonly moons: TrackedMoon[] = [];
|
private readonly moons: TrackedMoon[] = [];
|
||||||
@@ -190,6 +394,22 @@ export class SystemOrbitsRenderer {
|
|||||||
* following them each tick costs no allocation at all.
|
* following them each tick costs no allocation at all.
|
||||||
*/
|
*/
|
||||||
private tetherPoints: readonly THREE.Vector3[] = [];
|
private tetherPoints: readonly THREE.Vector3[] = [];
|
||||||
|
/** Derived surfaces still to paint, one a task, once the constructor is done; see `buildMarker`. */
|
||||||
|
private readonly surfacesToPaint: Array<() => void> = [];
|
||||||
|
private surfaceTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
private readonly deferSurface = (paint: () => void): void => {
|
||||||
|
this.surfacesToPaint.push(paint);
|
||||||
|
this.surfaceTimer ??= setTimeout(this.paintNextSurface, 0);
|
||||||
|
};
|
||||||
|
private readonly paintNextSurface = (): void => {
|
||||||
|
this.surfacesToPaint.shift()?.();
|
||||||
|
this.surfaceTimer = this.surfacesToPaint.length > 0 ? setTimeout(this.paintNextSurface, 0) : undefined;
|
||||||
|
};
|
||||||
|
/** Photographs still to put on their bodies, one a frame once loaded; see `buildMarker`. */
|
||||||
|
private readonly photographsToShow: Array<{ material: THREE.MeshStandardMaterial; texture: THREE.Texture }> = [];
|
||||||
|
private readonly deferPhotograph = (material: THREE.MeshStandardMaterial, texture: THREE.Texture): void => {
|
||||||
|
this.photographsToShow.push({ material, texture });
|
||||||
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
bodies: readonly BodyRecord[],
|
bodies: readonly BodyRecord[],
|
||||||
@@ -201,19 +421,19 @@ export class SystemOrbitsRenderer {
|
|||||||
* system is and therefore what it looks like. Omitted for a host that is not in the star
|
* 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.
|
* catalogue, leaving its bodies classified on size and density alone.
|
||||||
*/
|
*/
|
||||||
hostLuminositySolar?: number | null
|
hostLuminositySolar?: number | null,
|
||||||
|
/** The host star's effective temperature, which is the colour of the light it casts. */
|
||||||
|
hostTemperatureK?: number | null
|
||||||
) {
|
) {
|
||||||
const members: SystemMember[] = [];
|
const members: SystemMember[] = [];
|
||||||
const topLevelBodiesById = new Map<string, BodyRecord>();
|
const topLevelBodiesById = new Map<string, BodyRecord>();
|
||||||
|
|
||||||
// Measured before anything is built, because marker sizes are scaled against the span and
|
const topLevelOrbits = [
|
||||||
// the markers are created as the bodies are added.
|
...bodies.filter((body) => !body.parentBodyId).map(({ orbit }) => ({ axis: orbit.semiMajorAxisAu, eccentricity: orbit.eccentricity })),
|
||||||
const topLevelAxes = [
|
...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map(({ orbit }) => ({ axis: orbit.semiMajorAxisAu!, eccentricity: orbit.eccentricity ?? 0 }))
|
||||||
...bodies.filter((body) => !body.parentBodyId).map((body) => body.orbit.semiMajorAxisAu),
|
].filter(({ axis }) => Number.isFinite(axis) && axis > 0);
|
||||||
...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map((exoplanet) => exoplanet.orbit.semiMajorAxisAu!)
|
const topLevelAxes = topLevelOrbits.map(({ axis }) => axis);
|
||||||
].filter((axis) => Number.isFinite(axis) && axis > 0);
|
|
||||||
this.maxTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.max(...topLevelAxes) : 0;
|
this.maxTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.max(...topLevelAxes) : 0;
|
||||||
this.minTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.min(...topLevelAxes) : 0;
|
|
||||||
|
|
||||||
for (const body of bodies) {
|
for (const body of bodies) {
|
||||||
if (!body.parentBodyId) {
|
if (!body.parentBodyId) {
|
||||||
@@ -227,7 +447,16 @@ export class SystemOrbitsRenderer {
|
|||||||
}
|
}
|
||||||
// A body reaches here only when it has no parentBodyId, so `kind` is 'planet' or 'dwarf'.
|
// A body reaches here only when it has no parentBodyId, so `kind` is 'planet' or 'dwarf'.
|
||||||
const kind: SystemMemberKind = body.kind;
|
const kind: SystemMemberKind = body.kind;
|
||||||
const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar));
|
const tracked = this.addTopLevelBody(body.id, kind, body.orbit, body.rates, body.radiusKm, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar), { periodHours: body.rotationPeriodHours, elements: body.rotationalElements });
|
||||||
|
if (body.id === 'saturn') {
|
||||||
|
// A child of the sphere, so it lies in the equator the IAU pole turns the sphere into and
|
||||||
|
// is scaled with it where the marker is held to its pixel floor. Jupiter's, Uranus's and
|
||||||
|
// Neptune's rings are left out: dark, narrow or dusty, they are too faint to see here.
|
||||||
|
// In the sphere's own units, its radius being 1.
|
||||||
|
const ring = saturnRing(body.radiusKm, 1);
|
||||||
|
tracked.marker.add(ring);
|
||||||
|
this.trackDisposable(ring.geometry, ring.material as THREE.Material);
|
||||||
|
}
|
||||||
members.push({ id: body.id, kind, marker: tracked.marker });
|
members.push({ id: body.id, kind, marker: tracked.marker });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,8 +469,8 @@ export class SystemOrbitsRenderer {
|
|||||||
if (!parentTracked) {
|
if (!parentTracked) {
|
||||||
continue; // orphaned moon reference; skip rather than crash.
|
continue; // orphaned moon reference; skip rather than crash.
|
||||||
}
|
}
|
||||||
const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar));
|
const moon = this.addMoon(body.id, body.orbit, body.rates, body.radiusKm, parentTracked, moonFrame(body), appearanceForBody(body, bodies, hostLuminositySolar), { periodHours: body.rotationPeriodHours, elements: body.rotationalElements }, body.massRatio);
|
||||||
members.push({ id: body.id, kind: 'moon', marker: moon.marker });
|
members.push({ id: body.id, kind: 'moon', marker: moon.marker, parentId: parent.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every exoplanet in a system shares the same line of sight, so the frame is built once.
|
// Every exoplanet in a system shares the same line of sight, so the frame is built once.
|
||||||
@@ -256,27 +485,28 @@ export class SystemOrbitsRenderer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const elements = resolveOrbitalElements(exoplanet.orbit);
|
const elements = resolveOrbitalElements(exoplanet.orbit);
|
||||||
const radiusKm = exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined;
|
const radiusEarth = exoplanet.radiusEarth ?? radiusFromMassEarth(exoplanet.massEarth);
|
||||||
// Not `gmForParent(undefined)`: that assumes a solar-mass host for every system, and
|
const radiusKm = radiusEarth ? radiusEarth * EARTH_RADIUS_KM : undefined;
|
||||||
// most exoplanet hosts are red dwarfs a fraction of the Sun's mass.
|
// Not the Sun's: that assumes a solar-mass host for every system, and most exoplanet hosts
|
||||||
|
// are red dwarfs a fraction of the Sun's mass.
|
||||||
const gm = resolveGravitationalParameter({
|
const gm = resolveGravitationalParameter({
|
||||||
semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu,
|
semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu,
|
||||||
periodDays: exoplanet.periodDays,
|
periodDays: exoplanet.periodDays,
|
||||||
hostStarMassSolar: exoplanet.hostStarMassSolar
|
hostStarMassSolar: exoplanet.hostStarMassSolar
|
||||||
});
|
});
|
||||||
const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, gm, radiusKm, exoplanetFrame, appearanceForExoplanet(exoplanet, hostLuminositySolar));
|
const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, keplerRates(elements.semiMajorAxisAu, gm), radiusKm, exoplanetFrame, appearanceForExoplanet(exoplanet, hostLuminositySolar));
|
||||||
members.push({ id: exoplanet.id, kind: 'exoplanet', marker: tracked.marker });
|
members.push({ id: exoplanet.id, kind: 'exoplanet', marker: tracked.marker });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.members = members;
|
this.members = members;
|
||||||
|
|
||||||
// Which plane the system is read against follows from where its elements came from. Only the
|
// Which plane the system is read against follows from where its elements came from. Only the
|
||||||
// Sun has Horizons bodies and no system has both, so this is a choice between the two rather
|
// Sun has JPL bodies and no system has both, so this is a choice between the two rather
|
||||||
// than a compromise: the ecliptic if there are solar-system bodies, the sky plane otherwise.
|
// than a compromise: the ecliptic if there are solar-system bodies, the sky plane otherwise.
|
||||||
this.referenceFrame = bodies.some((body) => !body.parentBodyId) ? ECLIPTIC_FRAME.clone() : exoplanetFrame;
|
this.referenceFrame = bodies.some((body) => !body.parentBodyId) ? ECLIPTIC_FRAME.clone() : exoplanetFrame;
|
||||||
|
|
||||||
const rings = systemGridRingsAu(this.maxTopLevelSemiMajorAxisAu);
|
const rings = systemGridRingsAu(this.maxTopLevelSemiMajorAxisAu);
|
||||||
this.gridOuterRadiusAu = rings.length > 0 ? rings[rings.length - 1] : 0;
|
this.outermostRadiusAu = Math.max(rings.length > 0 ? rings[rings.length - 1] : 0, ...topLevelOrbits.map(({ axis, eccentricity }) => axis * (1 + eccentricity)));
|
||||||
if (rings.length > 0) {
|
if (rings.length > 0) {
|
||||||
this.grid = new PolarGridPlane({
|
this.grid = new PolarGridPlane({
|
||||||
ringRadii: rings,
|
ringRadii: rings,
|
||||||
@@ -299,14 +529,30 @@ export class SystemOrbitsRenderer {
|
|||||||
|
|
||||||
this.object.add(this.grid.object, this.tethers.object);
|
this.object.add(this.grid.object, this.tethers.object);
|
||||||
}
|
}
|
||||||
|
// The star lights its own system. The star marker itself is unlit — it is the source, not a
|
||||||
|
// surface — so nothing here changes how it is drawn.
|
||||||
|
this.object.add(starLight(hostTemperatureK));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recomputes every marker's position for the given Julian date. Call once per tick. */
|
/**
|
||||||
|
* Recomputes every marker's position for the given Julian date, UTC as the map's clock gives it:
|
||||||
|
* the orbits are taken at its TDB, as the spins are. Call once per tick.
|
||||||
|
*/
|
||||||
update(epochJd: number): void {
|
update(epochJd: number): void {
|
||||||
|
this.showNextPhotograph();
|
||||||
|
const jdTdb = tdbFromUtc(epochJd);
|
||||||
for (const body of this.topLevelBodies) {
|
for (const body of this.topLevelBodies) {
|
||||||
const orbital = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd);
|
const current = meanElementsAt(body.elements, body.rates, jdTdb);
|
||||||
|
const orbital = positionAtEpoch(current);
|
||||||
body.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(body.frame);
|
body.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(body.frame);
|
||||||
body.marker.position.copy(body.position);
|
body.marker.position.copy(body.position);
|
||||||
|
orientOrbit(body.orbitLine.quaternion, current, body.frame);
|
||||||
|
reshapeOrbitLine(body.orbitLine, current);
|
||||||
|
if (body.rotationalElements) {
|
||||||
|
bodyOrientation(body.rotationalElements, epochJd, body.marker.quaternion, body.id === 'earth');
|
||||||
|
} else if (body.rotationPeriodHours) {
|
||||||
|
body.marker.quaternion.copy(spinFor(current, body.frame, body.rotationPeriodHours, jdTdb - body.elements.epochJd));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const moon of this.moons) {
|
for (const moon of this.moons) {
|
||||||
@@ -315,8 +561,23 @@ export class SystemOrbitsRenderer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
moon.pivot.position.copy(parent.position);
|
moon.pivot.position.copy(parent.position);
|
||||||
const orbital = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd);
|
const current = meanElementsAt(moon.elements, moon.rates, jdTdb);
|
||||||
|
const orbital = positionAtEpoch(current);
|
||||||
moon.marker.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(moon.frame);
|
moon.marker.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(moon.frame);
|
||||||
|
orientOrbit(moon.orbitLine.quaternion, current, moon.frame);
|
||||||
|
if (moon.barycentre) {
|
||||||
|
// The planet's elements place the pair's barycentre, which is where the pivot is: the
|
||||||
|
// planet sits the moon's share of their separation back from it, the moon the rest out.
|
||||||
|
const { massRatio, parentOrbitLine } = moon.barycentre;
|
||||||
|
parent.marker.position.copy(parent.position).addScaledVector(moon.marker.position, -massRatio / (1 + massRatio));
|
||||||
|
moon.marker.position.multiplyScalar(1 / (1 + massRatio));
|
||||||
|
parentOrbitLine.quaternion.copy(moon.orbitLine.quaternion);
|
||||||
|
}
|
||||||
|
if (moon.rotationalElements) {
|
||||||
|
bodyOrientation(moon.rotationalElements, epochJd, moon.marker.quaternion);
|
||||||
|
} else if (moon.rotationPeriodHours) {
|
||||||
|
moon.marker.quaternion.copy(spinFor(current, moon.frame, moon.rotationPeriodHours, jdTdb - moon.elements.epochJd));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Moons are left out: their tether would land within a marker's width of their planet's and
|
// Moons are left out: their tether would land within a marker's width of their planet's and
|
||||||
@@ -324,9 +585,24 @@ export class SystemOrbitsRenderer {
|
|||||||
this.tethers?.setTargets(this.tetherPoints);
|
this.tethers?.setTargets(this.tetherPoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Looks up which system member a marker object belongs to (e.g. from a raycast hit). */
|
/** Puts the first photograph that has loaded on its body: one texture for the GPU a frame. */
|
||||||
|
private showNextPhotograph(): void {
|
||||||
|
const index = this.photographsToShow.findIndex(({ texture }) => texture.image);
|
||||||
|
if (index < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [{ material, texture }] = this.photographsToShow.splice(index, 1);
|
||||||
|
material.map = texture;
|
||||||
|
material.color.set(0xffffff);
|
||||||
|
material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up which system member a marker object belongs to (e.g. from a raycast hit), or a part
|
||||||
|
* of one: a ray through Saturn's rings picks Saturn.
|
||||||
|
*/
|
||||||
memberForObject(object: THREE.Object3D): SystemMember | undefined {
|
memberForObject(object: THREE.Object3D): SystemMember | undefined {
|
||||||
return this.members.find((member) => member.marker === object);
|
return this.members.find((member) => member.marker === object || member.marker === object.parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All marker objects, for raycasting. */
|
/** All marker objects, for raycasting. */
|
||||||
@@ -350,10 +626,15 @@ export class SystemOrbitsRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
clearTimeout(this.surfaceTimer);
|
||||||
|
this.surfacesToPaint.length = 0;
|
||||||
|
this.photographsToShow.length = 0;
|
||||||
this.grid?.dispose();
|
this.grid?.dispose();
|
||||||
this.tethers?.dispose();
|
this.tethers?.dispose();
|
||||||
for (const { geometry, material } of this.disposables) {
|
for (const { geometry, material } of this.disposables) {
|
||||||
geometry.dispose();
|
if (geometry !== MARKER_SPHERE) {
|
||||||
|
geometry.dispose();
|
||||||
|
}
|
||||||
material.dispose();
|
material.dispose();
|
||||||
}
|
}
|
||||||
// Detach as well as dispose. A star-to-star hop builds a new renderer and drops the old
|
// Detach as well as dispose. A star-to-star hop builds a new renderer and drops the old
|
||||||
@@ -368,18 +649,19 @@ export class SystemOrbitsRenderer {
|
|||||||
id: string,
|
id: string,
|
||||||
kind: SystemMemberKind,
|
kind: SystemMemberKind,
|
||||||
elements: OrbitalElements,
|
elements: OrbitalElements,
|
||||||
gmAu3PerDay2: number,
|
rates: MeanElementRates,
|
||||||
radiusKm: number | undefined,
|
radiusKm: number | undefined,
|
||||||
frame: THREE.Quaternion,
|
frame: THREE.Quaternion,
|
||||||
appearance?: PlanetAppearance
|
appearance?: PlanetAppearance,
|
||||||
|
rotation?: { periodHours?: number; elements?: RotationalElements }
|
||||||
): TrackedTopLevelBody {
|
): TrackedTopLevelBody {
|
||||||
const orbitLine = buildOrbitLine(elements, kind, frame);
|
const orbitLine = buildOrbitLine(elements, kind, frame);
|
||||||
const marker = buildMarker(kind, radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance);
|
const marker = buildMarker(id, kind, radiusKm, appearance, this.deferSurface, this.deferPhotograph);
|
||||||
this.object.add(orbitLine, marker);
|
this.object.add(orbitLine, marker);
|
||||||
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
||||||
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
||||||
|
|
||||||
const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, frame, position: new THREE.Vector3() };
|
const tracked: TrackedTopLevelBody = { id, kind, elements, rates, marker, orbitLine, frame, position: new THREE.Vector3(), rotationPeriodHours: rotation?.periodHours, rotationalElements: rotation?.elements };
|
||||||
this.topLevelBodies.push(tracked);
|
this.topLevelBodies.push(tracked);
|
||||||
return tracked;
|
return tracked;
|
||||||
}
|
}
|
||||||
@@ -387,21 +669,36 @@ export class SystemOrbitsRenderer {
|
|||||||
private addMoon(
|
private addMoon(
|
||||||
id: string,
|
id: string,
|
||||||
elements: OrbitalElements,
|
elements: OrbitalElements,
|
||||||
gmAu3PerDay2: number,
|
rates: MeanElementRates,
|
||||||
radiusKm: number | undefined,
|
radiusKm: number | undefined,
|
||||||
parent: TrackedTopLevelBody,
|
parent: TrackedTopLevelBody,
|
||||||
frame: THREE.Quaternion,
|
frame: THREE.Quaternion,
|
||||||
appearance?: PlanetAppearance
|
appearance?: PlanetAppearance,
|
||||||
|
rotation?: { periodHours?: number; elements?: RotationalElements },
|
||||||
|
massRatio?: number
|
||||||
): TrackedMoon {
|
): TrackedMoon {
|
||||||
const pivot = new THREE.Group();
|
const pivot = new THREE.Group();
|
||||||
const orbitLine = buildOrbitLine(elements, 'moon', frame);
|
const orbitLine = buildOrbitLine(elements, 'moon', frame);
|
||||||
const marker = buildMarker('moon', radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance);
|
const marker = buildMarker(id, 'moon', radiusKm, appearance, this.deferSurface, this.deferPhotograph);
|
||||||
pivot.add(orbitLine, marker);
|
pivot.add(orbitLine, marker);
|
||||||
this.object.add(pivot);
|
this.object.add(pivot);
|
||||||
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
||||||
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
||||||
|
|
||||||
const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, frame, pivot, parentId: parent.id };
|
let barycentre: TrackedMoon['barycentre'];
|
||||||
|
if (massRatio !== undefined) {
|
||||||
|
// Both orbits are the relative one, scaled: the moon's by the planet's share of the mass,
|
||||||
|
// the planet's by the moon's share and turned half round, since it is always opposite.
|
||||||
|
// Charon's then spans 17 460 km of radius, Pluto's 2 131, and neither passes through Pluto.
|
||||||
|
orbitLine.scale.setScalar(1 / (1 + massRatio));
|
||||||
|
const parentOrbitLine = buildOrbitLine(elements, parent.kind, frame);
|
||||||
|
parentOrbitLine.scale.setScalar(-massRatio / (1 + massRatio));
|
||||||
|
pivot.add(parentOrbitLine);
|
||||||
|
this.trackDisposable(parentOrbitLine.geometry, parentOrbitLine.material as THREE.Material);
|
||||||
|
barycentre = { massRatio, parentOrbitLine };
|
||||||
|
}
|
||||||
|
|
||||||
|
const moon: TrackedMoon = { id, elements, rates, marker, orbitLine, frame, pivot, parentId: parent.id, rotationPeriodHours: rotation?.periodHours, rotationalElements: rotation?.elements, barycentre };
|
||||||
this.moons.push(moon);
|
this.moons.push(moon);
|
||||||
return moon;
|
return moon;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||||
import { BookmarksStore } from '../../shared/state/bookmarks.store';
|
import { BookmarksStore } from '../../shared/state/bookmarks.store';
|
||||||
|
import { TimeStore } from '../../shared/state/time.store';
|
||||||
import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent } from './hud-dock.component';
|
import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent } from './hud-dock.component';
|
||||||
|
|
||||||
|
// jsdom has no matchMedia, which the dock reads once, at import: this one answers from `viewport`.
|
||||||
|
const viewport = vi.hoisted(() => {
|
||||||
|
const state = { wide: true };
|
||||||
|
window.matchMedia = (() => ({ get matches() { return state.wide; } })) as unknown as typeof window.matchMedia;
|
||||||
|
return state;
|
||||||
|
});
|
||||||
|
|
||||||
class EmptyDataLoaderService {
|
class EmptyDataLoaderService {
|
||||||
loadStars() {
|
loadStars() {
|
||||||
return Promise.resolve({ stars: [], positions: new Float32Array(0) });
|
return Promise.resolve({ stars: [], positions: new Float32Array(0) });
|
||||||
@@ -76,6 +84,17 @@ describe('HudDockComponent', () => {
|
|||||||
expect(tabNames()).toEqual(['Search', 'Readout', 'Routes', 'Bookmarks', 'Display']);
|
expect(tabNames()).toEqual(['Search', 'Readout', 'Routes', 'Bookmarks', 'Display']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers the clock alone, as a Clock tab, to a surface with no layers', () => {
|
||||||
|
fixture.componentRef.setInput('clock', true);
|
||||||
|
fixture.componentRef.setInput('defaultTab', 'display');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(tabNames()).toEqual(['Search', 'Bookmarks', 'Clock']);
|
||||||
|
const panel = host().querySelector('#dock-panel-display')!;
|
||||||
|
expect(panel.querySelector('[role="radiogroup"][aria-label="Clock rate"]')).not.toBeNull();
|
||||||
|
expect(panel.querySelector('#clock-date')).not.toBeNull();
|
||||||
|
expect(panel.textContent).not.toContain('Layers');
|
||||||
|
});
|
||||||
|
|
||||||
it('opens the default tab on mount and renders the readout from its inputs', () => {
|
it('opens the default tab on mount and renders the readout from its inputs', () => {
|
||||||
setReadout();
|
setReadout();
|
||||||
fixture.componentRef.setInput('defaultTab', 'readout');
|
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||||
@@ -141,7 +160,7 @@ describe('HudDockComponent', () => {
|
|||||||
fixture.componentRef.setInput('defaultTab', 'display');
|
fixture.componentRef.setInput('defaultTab', 'display');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const pressed = [...host().querySelectorAll('[aria-pressed]')].map((b) => `${b.textContent?.trim()}=${b.getAttribute('aria-pressed')}`);
|
const pressed = [...host().querySelectorAll('[aria-pressed]')].map((b) => `${b.textContent?.trim()}=${b.getAttribute('aria-pressed')}`);
|
||||||
expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true', 'Jump links=false', 'Plan view=false']);
|
expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true', 'Jump links=false', 'Plan view=false', 'Backwards=false']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('says how to keep a place, rather than showing an empty list', () => {
|
it('says how to keep a place, rather than showing an empty list', () => {
|
||||||
@@ -366,4 +385,168 @@ describe('HudDockComponent', () => {
|
|||||||
expect(host().querySelector<HTMLInputElement>('#route-to')!.value).toBe('Sirius');
|
expect(host().querySelector<HTMLInputElement>('#route-to')!.value).toBe('Sirius');
|
||||||
expect(host().querySelector<HTMLInputElement>('#route-range')!.value).toBe('6');
|
expect(host().querySelector<HTMLInputElement>('#route-range')!.value).toBe('6');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('clock', () => {
|
||||||
|
let time: TimeStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
time = TestBed.inject(TimeStore);
|
||||||
|
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
|
||||||
|
fixture.componentRef.setInput('defaultTab', 'display');
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllEnvs());
|
||||||
|
|
||||||
|
function button(name: string): HTMLButtonElement {
|
||||||
|
return [...host().querySelectorAll<HTMLButtonElement>('#dock-panel-display button')].find((b) => b.textContent?.trim() === name)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function radio(name: string): HTMLInputElement {
|
||||||
|
return [...host().querySelectorAll('#dock-panel-display label')].find((l) => l.textContent?.trim() === name)!.querySelector('input')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('runs the same rates backwards, and keeps the direction when the rate changes', () => {
|
||||||
|
button('Backwards').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(time.rate()).toBe(-1);
|
||||||
|
expect(button('Backwards').getAttribute('aria-pressed')).toBe('true');
|
||||||
|
// Still real time: the direction is not a fifth rate.
|
||||||
|
expect(radio('Real time').checked).toBe(true);
|
||||||
|
|
||||||
|
radio('1 d/s').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(time.rate()).toBe(-86_400);
|
||||||
|
expect(radio('1 d/s').checked).toBe(true);
|
||||||
|
|
||||||
|
button('Backwards').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(time.rate()).toBe(86_400);
|
||||||
|
expect(button('Backwards').getAttribute('aria-pressed')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the date field on the clock’s date, named and held to the window the elements hold for', () => {
|
||||||
|
const field = host().querySelector<HTMLInputElement>('#clock-date')!;
|
||||||
|
|
||||||
|
expect(field.type).toBe('datetime-local');
|
||||||
|
expect(field.value).toBe(time.date().toISOString().slice(0, 16));
|
||||||
|
expect(host().querySelector('label[for="clock-date"]')?.textContent?.trim()).toBe('Date (UTC)');
|
||||||
|
expect(field.min).toBe('0001-01-01T00:00');
|
||||||
|
expect(field.max).toBe('3000-01-01T00:00');
|
||||||
|
expect(host().querySelector(`#${field.getAttribute('aria-describedby')}`)?.textContent).toContain('AD 1 to AD 3000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jumps the clock to the date submitted, read as UTC', () => {
|
||||||
|
// Five and a half hours from UTC, so a field read as local time lands elsewhere: in UTC itself,
|
||||||
|
// where CI runs, the two readings are the same instant and this could not tell them apart.
|
||||||
|
vi.stubEnv('TZ', 'Asia/Kolkata');
|
||||||
|
const field = host().querySelector<HTMLInputElement>('#clock-date')!;
|
||||||
|
field.value = '2020-12-21T18:00';
|
||||||
|
button('Go').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(time.date().toISOString().slice(0, 16)).toBe('2020-12-21T18:00');
|
||||||
|
expect(time.atNow()).toBe(false);
|
||||||
|
|
||||||
|
// Back to now puts the field back on the present too, not on the date left behind.
|
||||||
|
button('Back to now').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(time.atNow()).toBe(true);
|
||||||
|
expect(host().querySelector<HTMLInputElement>('#clock-date')!.value).toBe(time.date().toISOString().slice(0, 16));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds the sheet away on a phone once a date is set, so the system it covered can be seen', () => {
|
||||||
|
viewport.wide = false;
|
||||||
|
try {
|
||||||
|
host().querySelector<HTMLInputElement>('#clock-date')!.value = '2020-12-21T18:00';
|
||||||
|
button('Go').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(host().querySelector('#dock-panel-display')).toBeNull();
|
||||||
|
} finally {
|
||||||
|
viewport.wide = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands focus to the tab that folded, not to the page, so Enter opens the panel again', () => {
|
||||||
|
viewport.wide = false;
|
||||||
|
try {
|
||||||
|
const field = host().querySelector<HTMLInputElement>('#clock-date')!;
|
||||||
|
field.focus();
|
||||||
|
field.value = '2020-12-21T18:00';
|
||||||
|
button('Go').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(document.activeElement?.id).toBe('dock-tab-display');
|
||||||
|
} finally {
|
||||||
|
viewport.wide = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the date strip on screen on a phone: the tabs give way to it, and scroll', () => {
|
||||||
|
// At 360 px the system view's five tabs take 397 px, and pushed the strip past the right
|
||||||
|
// edge, where nothing scrolls: after Go on a phone the date was nowhere on screen.
|
||||||
|
fixture.componentRef.setInput('date', '2020-12-21');
|
||||||
|
fixture.componentRef.setInput('range', '417 AU');
|
||||||
|
fixture.detectChanges();
|
||||||
|
const tabs = host().querySelector('[role="tablist"]')!.classList;
|
||||||
|
expect(tabs.contains('min-w-0') && tabs.contains('overflow-x-auto')).toBe(true);
|
||||||
|
// Its own scrollbar, where the browser draws one, thin and dark: a desktop's default was a
|
||||||
|
// light bar 15 px tall across the dock.
|
||||||
|
expect(tabs.contains('scheme-dark') && tabs.contains('[scrollbar-width:thin]')).toBe(true);
|
||||||
|
expect(host().querySelector('[data-testid="hud-date"]')!.classList.contains('shrink-0')).toBe(true);
|
||||||
|
// The range keeps its width where it is shown, or '417 AU' wraps and the row grows 20 px; on a
|
||||||
|
// phone it is not shown, where at the present it took the Display tab out of sight.
|
||||||
|
const range = [...host().querySelectorAll('p')].find((p) => p.textContent?.includes('Range'))!.classList;
|
||||||
|
expect(range.contains('shrink-0') && range.contains('max-sm:hidden')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('brings the tab that matters back into view once the date strip has narrowed the tabs', () => {
|
||||||
|
// jsdom lays nothing out; what is checked is which tab is asked to be in view, and when.
|
||||||
|
const scrolled: string[] = [];
|
||||||
|
HTMLElement.prototype.scrollIntoView = function (this: HTMLElement) {
|
||||||
|
scrolled.push(this.id);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
// On a phone: the tab focus went back to, which after Go sat wholly out of sight.
|
||||||
|
viewport.wide = false;
|
||||||
|
host().querySelector<HTMLInputElement>('#clock-date')!.value = '2020-12-21T18:00';
|
||||||
|
button('Go').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
scrolled.length = 0;
|
||||||
|
fixture.componentRef.setInput('date', '2020-12-21');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(scrolled).toEqual(['dock-tab-display']);
|
||||||
|
// On a wider window, where the panel stays open: its tab, focus being in the panel.
|
||||||
|
viewport.wide = true;
|
||||||
|
fixture.componentRef.setInput('date', '');
|
||||||
|
fixture.detectChanges();
|
||||||
|
tab('Display').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
host().querySelector<HTMLInputElement>('#clock-date')!.focus();
|
||||||
|
scrolled.length = 0;
|
||||||
|
fixture.componentRef.setInput('date', '2020-12-21');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(scrolled).toEqual(['dock-tab-display']);
|
||||||
|
} finally {
|
||||||
|
viewport.wide = true;
|
||||||
|
delete (HTMLElement.prototype as Partial<HTMLElement>).scrollIntoView;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the panel open on a wide screen, where it covers little of the scene', () => {
|
||||||
|
host().querySelector<HTMLInputElement>('#clock-date')!.value = '2020-12-21T18:00';
|
||||||
|
button('Go').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(host().querySelector('#dock-panel-display')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills the date field again with the clock’s date when the panel is opened again', () => {
|
||||||
|
time.setDate(new Date('2020-12-21T18:00Z'));
|
||||||
|
fixture.componentInstance.toggleTab('display');
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.componentInstance.toggleTab('display');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(host().querySelector<HTMLInputElement>('#clock-date')!.value).toBe('2020-12-21T18:00');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, ElementRef, HostListener, inject, input, OnInit, output, signal, viewChild } from '@angular/core';
|
import {
|
||||||
|
afterRenderEffect,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
ElementRef,
|
||||||
|
HostListener,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
OnInit,
|
||||||
|
output,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store';
|
import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store';
|
||||||
|
import { CLOCK_WINDOW, TIME_RATES, TimeStore } from '../../shared/state/time.store';
|
||||||
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
|
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
|
||||||
import { SearchComponent } from '../search/search.component';
|
import { SearchComponent } from '../search/search.component';
|
||||||
import { RouteRequest, RouteResult, RoutesPanelComponent, RouteStarOption } from './routes-panel.component';
|
import {
|
||||||
|
RouteRequest,
|
||||||
|
RouteResult,
|
||||||
|
RoutesPanelComponent,
|
||||||
|
RouteStarOption,
|
||||||
|
} from './routes-panel.component';
|
||||||
|
|
||||||
export interface HudReadout {
|
export interface HudReadout {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
@@ -31,7 +50,16 @@ export interface HudDisplay {
|
|||||||
readonly plan: boolean;
|
readonly plan: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true, systems: true, jumpLinks: false, plan: false };
|
export const DEFAULT_HUD_DISPLAY: HudDisplay = {
|
||||||
|
labels: true,
|
||||||
|
orbits: true,
|
||||||
|
grid: true,
|
||||||
|
deepSky: true,
|
||||||
|
sky: true,
|
||||||
|
systems: true,
|
||||||
|
jumpLinks: false,
|
||||||
|
plan: false,
|
||||||
|
};
|
||||||
|
|
||||||
const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
||||||
{ key: 'labels', label: 'Labels' },
|
{ key: 'labels', label: 'Labels' },
|
||||||
@@ -41,17 +69,26 @@ const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
|||||||
{ key: 'sky', label: 'Sky' },
|
{ key: 'sky', label: 'Sky' },
|
||||||
{ key: 'systems', label: 'Systems' },
|
{ key: 'systems', label: 'Systems' },
|
||||||
{ key: 'jumpLinks', label: 'Jump links' },
|
{ key: 'jumpLinks', label: 'Jump links' },
|
||||||
{ key: 'plan', label: 'Plan view' }
|
{ key: 'plan', label: 'Plan view' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | 'display';
|
export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | 'display';
|
||||||
|
|
||||||
const TAB_LABELS: Record<DockTab, string> = { search: 'Search', readout: 'Readout', routes: 'Routes', bookmarks: 'Bookmarks', display: 'Display' };
|
const TAB_LABELS: Record<DockTab, string> = {
|
||||||
|
search: 'Search',
|
||||||
|
readout: 'Readout',
|
||||||
|
routes: 'Routes',
|
||||||
|
bookmarks: 'Bookmarks',
|
||||||
|
display: 'Display',
|
||||||
|
};
|
||||||
|
|
||||||
/** Tailwind's `sm` breakpoint: below it the dock is a bare tab strip and its panel is a sheet. */
|
/** Tailwind's `sm` breakpoint: below it the dock is a bare tab strip and its panel is a sheet. */
|
||||||
const WIDE_VIEWPORT = '(min-width: 640px)';
|
const WIDE_VIEWPORT = '(min-width: 640px)';
|
||||||
/** One live query, read on every pointer-down, rather than a new MediaQueryList per read. */
|
/** One live query, read on every pointer-down, rather than a new MediaQueryList per read. */
|
||||||
const wideViewportQuery = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(WIDE_VIEWPORT) : null;
|
const wideViewportQuery =
|
||||||
|
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||||
|
? window.matchMedia(WIDE_VIEWPORT)
|
||||||
|
: null;
|
||||||
|
|
||||||
function isWideViewport(): boolean {
|
function isWideViewport(): boolean {
|
||||||
return wideViewportQuery?.matches ?? true;
|
return wideViewportQuery?.matches ?? true;
|
||||||
@@ -74,7 +111,10 @@ function isWideViewport(): boolean {
|
|||||||
selector: 'app-hud-dock',
|
selector: 'app-hud-dock',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [BookmarkIconComponent, RoutesPanelComponent, SearchComponent],
|
imports: [BookmarkIconComponent, RoutesPanelComponent, SearchComponent],
|
||||||
host: { class: 'pointer-events-none fixed inset-x-2 bottom-2 z-20 block font-body sm:inset-x-6 sm:bottom-6' },
|
host: {
|
||||||
|
class:
|
||||||
|
'pointer-events-none fixed inset-x-2 bottom-2 z-20 block font-body sm:inset-x-6 sm:bottom-6',
|
||||||
|
},
|
||||||
template: `
|
template: `
|
||||||
<!-- The column is transparent to the pointer and each surface in it opts back in: it is as
|
<!-- The column is transparent to the pointer and each surface in it opts back in: it is as
|
||||||
wide as the strip and as tall as the open panel, so a solid one would swallow every
|
wide as the strip and as tall as the open panel, so a solid one would swallow every
|
||||||
@@ -85,29 +125,55 @@ function isWideViewport(): boolean {
|
|||||||
locking on, once per switch, never per keystroke. -->
|
locking on, once per switch, never per keystroke. -->
|
||||||
@switch (tab) {
|
@switch (tab) {
|
||||||
@case ('search') {
|
@case ('search') {
|
||||||
<section id="dock-panel-search" role="tabpanel" aria-labelledby="dock-tab-search" class="hud-acquire pointer-events-auto mb-2 w-full max-w-xl">
|
<section
|
||||||
|
id="dock-panel-search"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="dock-tab-search"
|
||||||
|
class="hud-acquire pointer-events-auto mb-2 w-full max-w-xl"
|
||||||
|
>
|
||||||
<app-search (picked)="onPicked()" />
|
<app-search (picked)="onPicked()" />
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@case ('readout') {
|
@case ('readout') {
|
||||||
<section id="dock-panel-readout" role="tabpanel" aria-labelledby="dock-tab-readout" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3">
|
<section
|
||||||
|
id="dock-panel-readout"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="dock-tab-readout"
|
||||||
|
class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3"
|
||||||
|
>
|
||||||
<p class="type-label text-muted">{{ eyebrow() }}</p>
|
<p class="type-label text-muted">{{ eyebrow() }}</p>
|
||||||
<div class="mt-1 flex items-start gap-2">
|
<div class="mt-1 flex items-start gap-2">
|
||||||
<p data-testid="hud-title" class="min-w-0 flex-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
|
<p
|
||||||
|
data-testid="hud-title"
|
||||||
|
class="min-w-0 flex-1 text-lg font-bold tracking-[0.04em] text-text uppercase"
|
||||||
|
>
|
||||||
|
{{ title() }}
|
||||||
|
</p>
|
||||||
<!-- Against null, not against falsiness: the Sun's catalogue id is 0, and a
|
<!-- Against null, not against falsiness: the Sun's catalogue id is 0, and a
|
||||||
truthiness test is what would quietly make the Solar System the one
|
truthiness test is what would quietly make the Solar System the one
|
||||||
system nobody could keep. -->
|
system nobody could keep. -->
|
||||||
@if (keepableStarId() !== null) {
|
@if (keepableStarId() !== null) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
[attr.aria-label]="(bookmarks.has('star', keepableStarId()!) ? 'Forget ' : 'Keep ') + title()"
|
[attr.aria-label]="
|
||||||
[attr.aria-pressed]="bookmarks.has('star', keepableStarId()!)"
|
(bookmarks.has('star', keepableStarId()!) ? 'Forget ' : 'Keep ') + title()
|
||||||
(click)="bookmarks.toggle({ kind: 'star', id: keepableStarId()!, name: title() })"
|
"
|
||||||
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
[attr.aria-pressed]="bookmarks.has('star', keepableStarId()!)"
|
||||||
[class]="bookmarks.has('star', keepableStarId()!) ? 'text-accent' : 'text-muted hover:text-accent'"
|
(click)="
|
||||||
>
|
bookmarks.toggle({ kind: 'star', id: keepableStarId()!, name: title() })
|
||||||
<app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('star', keepableStarId()!)" />
|
"
|
||||||
</button>
|
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
[class]="
|
||||||
|
bookmarks.has('star', keepableStarId()!)
|
||||||
|
? 'text-accent'
|
||||||
|
: 'text-muted hover:text-accent'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<app-bookmark-icon
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
[kept]="bookmarks.has('star', keepableStarId()!)"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (subtitle()) {
|
@if (subtitle()) {
|
||||||
@@ -117,30 +183,54 @@ function isWideViewport(): boolean {
|
|||||||
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
||||||
@for (readout of readouts(); track readout.label) {
|
@for (readout of readouts(); track readout.label) {
|
||||||
<div>
|
<div>
|
||||||
<dt class="type-label text-muted">{{ readout.label }}@if (readout.derived) {<span class="text-accent/80" aria-hidden="true">*</span>}</dt>
|
<dt class="type-label text-muted">
|
||||||
|
{{ readout.label }}
|
||||||
|
@if (readout.derived) {
|
||||||
|
<span class="text-accent/80" aria-hidden="true">*</span>
|
||||||
|
}
|
||||||
|
</dt>
|
||||||
<dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
<dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
}
|
}
|
||||||
@if (note() || hasDerived()) {
|
@if (note() || hasDerived()) {
|
||||||
<p class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted">@if (hasDerived()) {<span class="text-accent/80">*</span> Derived, not catalogued. }{{ note() }}</p>
|
<p
|
||||||
|
class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted"
|
||||||
|
>
|
||||||
|
@if (hasDerived()) {
|
||||||
|
<span class="text-accent/80">*</span> Derived, not catalogued.
|
||||||
|
}
|
||||||
|
{{ note() }}
|
||||||
|
</p>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@case ('bookmarks') {
|
@case ('bookmarks') {
|
||||||
<section id="dock-panel-bookmarks" role="tabpanel" aria-labelledby="dock-tab-bookmarks" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg">
|
<section
|
||||||
|
id="dock-panel-bookmarks"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="dock-tab-bookmarks"
|
||||||
|
class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg"
|
||||||
|
>
|
||||||
@if (bookmarks.bookmarks().length) {
|
@if (bookmarks.bookmarks().length) {
|
||||||
<ul class="max-h-64 divide-y divide-border/25 overflow-y-auto">
|
<ul class="max-h-64 divide-y divide-border/25 overflow-y-auto">
|
||||||
@for (bookmark of bookmarks.bookmarks(); track bookmark.kind + ':' + bookmark.id) {
|
@for (
|
||||||
|
bookmark of bookmarks.bookmarks();
|
||||||
|
track bookmark.kind + ':' + bookmark.id
|
||||||
|
) {
|
||||||
<li class="flex items-stretch">
|
<li class="flex items-stretch">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="onBookmarkChosen(bookmark)"
|
(click)="onBookmarkChosen(bookmark)"
|
||||||
class="flex min-w-0 flex-1 items-baseline gap-3 px-3 py-2 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
class="flex min-w-0 flex-1 items-baseline gap-3 px-3 py-2 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
>
|
>
|
||||||
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ bookmark.name }}</span>
|
<span class="min-w-0 flex-1 truncate text-sm text-text">{{
|
||||||
<span class="type-label shrink-0 text-muted">{{ bookmark.kind === 'star' ? 'System' : 'Body' }}</span>
|
bookmark.name
|
||||||
|
}}</span>
|
||||||
|
<span class="type-label shrink-0 text-muted">{{
|
||||||
|
bookmark.kind === 'star' ? 'System' : 'Body'
|
||||||
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -148,7 +238,15 @@ function isWideViewport(): boolean {
|
|||||||
(click)="bookmarks.remove(bookmark.kind, bookmark.id)"
|
(click)="bookmarks.remove(bookmark.kind, bookmark.id)"
|
||||||
class="shrink-0 border-l border-border/25 px-3 text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
class="shrink-0 border-l border-border/25 px-3 text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
>
|
>
|
||||||
<svg class="h-3 w-3" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">
|
<svg
|
||||||
|
class="h-3 w-3"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<path d="M3 3l8 8M11 3l-8 8" />
|
<path d="M3 3l8 8M11 3l-8 8" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -157,29 +255,136 @@ function isWideViewport(): boolean {
|
|||||||
</ul>
|
</ul>
|
||||||
} @else {
|
} @else {
|
||||||
<p class="px-3 py-3 text-sm text-muted">
|
<p class="px-3 py-3 text-sm text-muted">
|
||||||
Nothing kept yet. The <app-bookmark-icon class="inline-block h-3.5 w-3.5 -mb-0.5 text-accent" /> on a readout or a body keeps it here, in this browser.
|
Nothing kept yet. The
|
||||||
|
<app-bookmark-icon class="inline-block h-3.5 w-3.5 -mb-0.5 text-accent" /> on a
|
||||||
|
readout or a body keeps it here, in this browser.
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@case ('display') {
|
@case ('display') {
|
||||||
<section id="dock-panel-display" role="tabpanel" aria-labelledby="dock-tab-display" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3">
|
<section
|
||||||
<p class="type-label text-muted">Layers</p>
|
id="dock-panel-display"
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
role="tabpanel"
|
||||||
@for (layer of layers; track layer.key) {
|
aria-labelledby="dock-tab-display"
|
||||||
|
class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3"
|
||||||
|
>
|
||||||
|
@if (display()) {
|
||||||
|
<p class="type-label text-muted">Layers</p>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
@for (layer of layers; track layer.key) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[attr.aria-pressed]="isOn(layer.key)"
|
||||||
|
(click)="toggleLayer(layer.key)"
|
||||||
|
class="type-label flex items-center gap-2 border px-3 py-1.5 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
[class]="
|
||||||
|
isOn(layer.key)
|
||||||
|
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
||||||
|
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<!-- The state mark: a filled tick when the layer is drawn, hollow when it is not. -->
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-1.5 w-1.5 border border-current"
|
||||||
|
[class.bg-current]="isOn(layer.key)"
|
||||||
|
></span>
|
||||||
|
{{ layer.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- The clock. Orbits and rotations are both functions of a date, so this is the
|
||||||
|
difference between a still picture and an orrery. -->
|
||||||
|
<p class="type-label text-muted" [class.mt-4]="display()">Clock</p>
|
||||||
|
<!-- Radios rather than buttons: the rates are one-of-four, and the native control
|
||||||
|
carries that to a screen reader and to the arrow keys without any script. -->
|
||||||
|
<div
|
||||||
|
class="mt-2 flex flex-wrap items-center gap-2"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Clock rate"
|
||||||
|
>
|
||||||
|
<!-- A rate is picked by its size and the toggle after the radios says which way it
|
||||||
|
runs, so a month a second backwards is the same radio as forwards. -->
|
||||||
|
@for (rate of timeRates; track rate.secondsPerSecond) {
|
||||||
|
<label
|
||||||
|
class="type-label cursor-pointer border px-3 py-1.5 transition-colors has-[:focus-visible]:outline has-[:focus-visible]:outline-1 has-[:focus-visible]:-outline-offset-1 has-[:focus-visible]:outline-accent"
|
||||||
|
[class]="
|
||||||
|
Math.abs(time.rate()) === rate.secondsPerSecond
|
||||||
|
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
||||||
|
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="clock-rate"
|
||||||
|
class="sr-only"
|
||||||
|
[value]="rate.secondsPerSecond"
|
||||||
|
[checked]="Math.abs(time.rate()) === rate.secondsPerSecond"
|
||||||
|
(change)="time.setRate(Math.sign(time.rate()) * rate.secondsPerSecond)"
|
||||||
|
/>
|
||||||
|
{{ rate.label }}
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[attr.aria-pressed]="time.rate() < 0"
|
||||||
|
(click)="time.setRate(-time.rate())"
|
||||||
|
class="type-label flex items-center gap-2 border px-3 py-1.5 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
[class]="
|
||||||
|
time.rate() < 0
|
||||||
|
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
||||||
|
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-1.5 w-1.5 border border-current"
|
||||||
|
[class.bg-current]="time.rate() < 0"
|
||||||
|
></span>
|
||||||
|
Backwards
|
||||||
|
</button>
|
||||||
|
@if (!time.atNow()) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
[attr.aria-pressed]="isOn(layer.key)"
|
(click)="backToNow()"
|
||||||
(click)="toggleLayer(layer.key)"
|
class="type-label border border-border/60 px-3 py-1.5 text-muted transition-colors hover:border-accent/70 hover:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
class="type-label flex items-center gap-2 border px-3 py-1.5 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
|
||||||
[class]="isOn(layer.key) ? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18' : 'border-border/60 text-muted hover:border-border hover:text-text'"
|
|
||||||
>
|
>
|
||||||
<!-- The state mark: a filled tick when the layer is drawn, hollow when it is not. -->
|
Back to now
|
||||||
<span aria-hidden="true" class="h-1.5 w-1.5 border border-current" [class.bg-current]="isOn(layer.key)"></span>
|
|
||||||
{{ layer.label }}
|
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- A form, so Enter in the field goes there and the browser holds the field to its
|
||||||
|
min and max before anything is submitted. Submitted rather than applied on each
|
||||||
|
change: Chrome reports a year typed digit by digit as 0002, 0020, 0202 and 2020,
|
||||||
|
and the sky would jump through every one. -->
|
||||||
|
<form class="mt-2 flex flex-wrap items-center gap-2" (submit)="goToDate($event)">
|
||||||
|
<label for="clock-date" class="type-label text-muted">Date (UTC)</label>
|
||||||
|
<input
|
||||||
|
id="clock-date"
|
||||||
|
name="date"
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
[min]="clockWindow.min"
|
||||||
|
[max]="clockWindow.max"
|
||||||
|
[value]="dateField()"
|
||||||
|
aria-describedby="clock-date-window"
|
||||||
|
class="hud-surface min-w-0 flex-1 px-2.5 py-1 text-sm text-text tabular-nums caret-accent scheme-dark focus:border-accent focus:outline-none sm:flex-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="type-label border border-border/60 px-3 py-1.5 text-muted transition-colors hover:border-accent/70 hover:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
Go
|
||||||
|
</button>
|
||||||
|
<!-- One line: on a phone the panel is a sheet over the system it sets the date of, and
|
||||||
|
each card already says how far its own orbit strays. -->
|
||||||
|
<p id="clock-date-window" class="w-full text-[10px] text-muted">
|
||||||
|
AD 1 to AD 3000, where the planets’ elements hold.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,7 +395,13 @@ function isWideViewport(): boolean {
|
|||||||
back with what it had, so it is not acquiring anything — and for the 380 ms the wipe
|
back with what it had, so it is not acquiring anything — and for the 380 ms the wipe
|
||||||
runs, its clip path swallows clicks on the suggestions it just brought back. -->
|
runs, its clip path swallows clicks on the suggestions it just brought back. -->
|
||||||
@if (routing()) {
|
@if (routing()) {
|
||||||
<section id="dock-panel-routes" role="tabpanel" aria-labelledby="dock-tab-routes" [hidden]="activeTab() !== 'routes'" class="hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-xl px-4 py-3">
|
<section
|
||||||
|
id="dock-panel-routes"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="dock-tab-routes"
|
||||||
|
[hidden]="activeTab() !== 'routes'"
|
||||||
|
class="hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-xl px-4 py-3"
|
||||||
|
>
|
||||||
<app-routes-panel
|
<app-routes-panel
|
||||||
[result]="routeResult()"
|
[result]="routeResult()"
|
||||||
[pending]="routePending()"
|
[pending]="routePending()"
|
||||||
@@ -205,7 +416,15 @@ function isWideViewport(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="hud-brackets hud-surface pointer-events-auto flex w-full items-stretch">
|
<div class="hud-brackets hud-surface pointer-events-auto flex w-full items-stretch">
|
||||||
<div role="tablist" aria-label="Dock" class="flex items-stretch divide-x divide-border/40">
|
<!-- The tabs give way to the date and the range, and scroll: the five of the system view
|
||||||
|
take 397 px, and on a portrait phone they pushed the date off the right edge. Where
|
||||||
|
the browser draws a scrollbar of its own, it is a thin dark one: on a desktop window
|
||||||
|
under 770 px wide its default was a light bar 15 px tall across the dark dock. -->
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Dock"
|
||||||
|
class="flex min-w-0 scheme-dark items-stretch divide-x divide-border/40 overflow-x-auto [scrollbar-width:thin]"
|
||||||
|
>
|
||||||
@for (tab of tabs(); track tab) {
|
@for (tab of tabs(); track tab) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -215,21 +434,41 @@ function isWideViewport(): boolean {
|
|||||||
[attr.aria-controls]="activeTab() === tab ? 'dock-panel-' + tab : null"
|
[attr.aria-controls]="activeTab() === tab ? 'dock-panel-' + tab : null"
|
||||||
(click)="toggleTab(tab)"
|
(click)="toggleTab(tab)"
|
||||||
class="type-eyebrow px-3 py-2 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent sm:px-4"
|
class="type-eyebrow px-3 py-2 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent sm:px-4"
|
||||||
[class]="activeTab() === tab ? 'bg-accent/15 text-accent' : 'text-muted hover:bg-accent/8 hover:text-accent'"
|
[class]="
|
||||||
|
activeTab() === tab
|
||||||
|
? 'bg-accent/15 text-accent'
|
||||||
|
: 'text-muted hover:bg-accent/8 hover:text-accent'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ tabLabel(tab) }}
|
{{ tabLabel(tab) }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Only while the map is away from the present: at the present the date is
|
||||||
|
today's, which the reader's own machine already says. -->
|
||||||
|
@if (date()) {
|
||||||
|
<p
|
||||||
|
class="ml-auto flex shrink-0 items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4"
|
||||||
|
data-testid="hud-date"
|
||||||
|
>
|
||||||
|
<span class="type-label text-muted">Date</span>
|
||||||
|
<span class="text-sm text-accent tabular-nums">{{ date() }}</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<!-- Not on a phone, where it never showed before the tabs gave way to it: kept, its 121 px
|
||||||
|
took the Display tab out of sight at the present, the state the map opens in. -->
|
||||||
@if (range()) {
|
@if (range()) {
|
||||||
<p class="ml-auto flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4">
|
<p
|
||||||
|
class="flex shrink-0 items-baseline gap-2 border-l border-border/40 px-3 py-2 max-sm:hidden sm:px-4"
|
||||||
|
[class.ml-auto]="!date()"
|
||||||
|
>
|
||||||
<span class="type-label text-muted">Range</span>
|
<span class="type-label text-muted">Range</span>
|
||||||
<span class="text-sm text-accent tabular-nums">{{ range() }}</span>
|
<span class="text-sm text-accent tabular-nums">{{ range() }}</span>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`,
|
||||||
})
|
})
|
||||||
export class HudDockComponent implements OnInit {
|
export class HudDockComponent implements OnInit {
|
||||||
/** Readout panel contents. An empty title means there is nothing to read out, and no tab for it. */
|
/** Readout panel contents. An empty title means there is nothing to read out, and no tab for it. */
|
||||||
@@ -241,8 +480,15 @@ export class HudDockComponent implements OnInit {
|
|||||||
readonly note = input('');
|
readonly note = input('');
|
||||||
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
|
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
|
||||||
readonly range = input('');
|
readonly range = input('');
|
||||||
/** Layer state; `null` means the surface has no layers to toggle and no Display tab. */
|
/** The date the sky is drawn for; empty while the map is drawn for the present. */
|
||||||
|
readonly date = input('');
|
||||||
|
/**
|
||||||
|
* Layer state; `null` means the surface has no layers to toggle, and no Display tab unless it
|
||||||
|
* has the clock.
|
||||||
|
*/
|
||||||
readonly display = input<HudDisplay | null>(null);
|
readonly display = input<HudDisplay | null>(null);
|
||||||
|
/** The clock without the layers, for a surface that is drawn at its date: the tab is then "Clock". */
|
||||||
|
readonly clock = input(false);
|
||||||
/** Which panel is open on a wide viewport when the dock mounts. */
|
/** Which panel is open on a wide viewport when the dock mounts. */
|
||||||
readonly defaultTab = input<DockTab | null>(null);
|
readonly defaultTab = input<DockTab | null>(null);
|
||||||
/** Routing: what the scene found, what it offers for the fields, and where the view is. */
|
/** Routing: what the scene found, what it offers for the fields, and where the view is. */
|
||||||
@@ -271,22 +517,49 @@ export class HudDockComponent implements OnInit {
|
|||||||
// Always offered, even with nothing in it: it is the only place that says the map can keep
|
// Always offered, even with nothing in it: it is the only place that says the map can keep
|
||||||
// anything at all, and a tab that appears once you already know is a tab that never taught.
|
// anything at all, and a tab that appears once you already know is a tab that never taught.
|
||||||
'bookmarks',
|
'bookmarks',
|
||||||
...(this.display() ? (['display'] as const) : [])
|
...(this.display() || this.clock() ? (['display'] as const) : []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
readonly activeTab = signal<DockTab | null>(null);
|
readonly activeTab = signal<DockTab | null>(null);
|
||||||
|
|
||||||
readonly bookmarks = inject(BookmarksStore);
|
readonly bookmarks = inject(BookmarksStore);
|
||||||
|
readonly time = inject(TimeStore);
|
||||||
|
readonly timeRates = TIME_RATES;
|
||||||
|
readonly clockWindow = CLOCK_WINDOW;
|
||||||
|
protected readonly Math = Math;
|
||||||
|
/**
|
||||||
|
* What the date field holds when the panel opens: the clock's date at that moment. Not bound to
|
||||||
|
* the running clock, which would rewrite the field under the reader's typing on every render.
|
||||||
|
*/
|
||||||
|
readonly dateField = signal('');
|
||||||
|
|
||||||
private readonly search = viewChild(SearchComponent);
|
private readonly search = viewChild(SearchComponent);
|
||||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
|
|
||||||
|
private readonly dateShown = computed(() => this.date() !== '');
|
||||||
|
/**
|
||||||
|
* The tab list gives way to the date strip but keeps its scroll, so once the strip is drawn the
|
||||||
|
* tab that matters can be wholly out of sight: after Go on a phone, the tab focus went back to
|
||||||
|
* (0 of its 79 px at 360, 390 and 412 wide), and on a desktop window 640 to 770 px wide, the tab
|
||||||
|
* of the panel left open. Brought back into view whenever the strip comes or goes: the focused
|
||||||
|
* tab, else the selected one.
|
||||||
|
*/
|
||||||
|
private readonly keepTabInView = afterRenderEffect(() => {
|
||||||
|
this.dateShown();
|
||||||
|
const list = this.host.nativeElement.querySelector('[role="tablist"]');
|
||||||
|
const focused = document.activeElement;
|
||||||
|
const tab = focused && list?.contains(focused) ? focused : list?.querySelector('[aria-selected="true"]');
|
||||||
|
// Optional: jsdom, which the unit tests run in, lays nothing out and has no scrollIntoView.
|
||||||
|
tab?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
||||||
|
});
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.activeTab.set(isWideViewport() ? this.defaultTab() : null);
|
this.activeTab.set(isWideViewport() ? this.defaultTab() : null);
|
||||||
|
this.fillDateField();
|
||||||
}
|
}
|
||||||
|
|
||||||
tabLabel(tab: DockTab): string {
|
tabLabel(tab: DockTab): string {
|
||||||
return TAB_LABELS[tab];
|
return tab === 'display' && !this.display() ? 'Clock' : TAB_LABELS[tab];
|
||||||
}
|
}
|
||||||
|
|
||||||
isOn(key: keyof HudDisplay): boolean {
|
isOn(key: keyof HudDisplay): boolean {
|
||||||
@@ -295,6 +568,35 @@ export class HudDockComponent implements OnInit {
|
|||||||
|
|
||||||
toggleTab(tab: DockTab): void {
|
toggleTab(tab: DockTab): void {
|
||||||
this.activeTab.set(this.activeTab() === tab ? null : tab);
|
this.activeTab.set(this.activeTab() === tab ? null : tab);
|
||||||
|
this.fillDateField();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The field's value is read as UTC, which is what the strip and the note print dates in. */
|
||||||
|
goToDate(event: SubmitEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
const field = (event.target as HTMLFormElement).elements.namedItem('date') as HTMLInputElement;
|
||||||
|
if (this.time.setDate(new Date(`${field.value}Z`))) {
|
||||||
|
// The field now says what the signal behind it does, so a later reset that fills it with
|
||||||
|
// the present is a change the binding writes back, not one it drops as the same value.
|
||||||
|
this.dateField.set(field.value);
|
||||||
|
// On a phone the sheet covers the system it has just set the date of: at 360 by 640 every
|
||||||
|
// orbit lies behind it. The thing to look at is now the scene, as after a search.
|
||||||
|
// Focus goes back to the tab that folded, not to the page: the form it was in is gone.
|
||||||
|
if (!isWideViewport()) {
|
||||||
|
const tab = this.activeTab();
|
||||||
|
this.activeTab.set(null);
|
||||||
|
this.host.nativeElement.querySelector<HTMLElement>(`#dock-tab-${tab}`)?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
backToNow(): void {
|
||||||
|
this.time.reset();
|
||||||
|
this.fillDateField();
|
||||||
|
}
|
||||||
|
|
||||||
|
private fillDateField(): void {
|
||||||
|
this.dateField.set(this.time.date().toISOString().slice(0, 16));
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleLayer(key: keyof HudDisplay): void {
|
toggleLayer(key: keyof HudDisplay): void {
|
||||||
@@ -331,7 +633,10 @@ export class HudDockComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = event.target as HTMLElement | null;
|
const target = event.target as HTMLElement | null;
|
||||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
if (
|
||||||
|
target &&
|
||||||
|
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -343,7 +648,11 @@ export class HudDockComponent implements OnInit {
|
|||||||
/** On a narrow viewport the panel is a sheet over the scene: tapping the scene folds it away. */
|
/** On a narrow viewport the panel is a sheet over the scene: tapping the scene folds it away. */
|
||||||
@HostListener('document:pointerdown', ['$event'])
|
@HostListener('document:pointerdown', ['$event'])
|
||||||
onDocumentPointerDown(event: PointerEvent): void {
|
onDocumentPointerDown(event: PointerEvent): void {
|
||||||
if (this.activeTab() && !isWideViewport() && !this.host.nativeElement.contains(event.target as Node)) {
|
if (
|
||||||
|
this.activeTab() &&
|
||||||
|
!isWideViewport() &&
|
||||||
|
!this.host.nativeElement.contains(event.target as Node)
|
||||||
|
) {
|
||||||
this.activeTab.set(null);
|
this.activeTab.set(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ describe('rankSearchResults', () => {
|
|||||||
expect(rank(entries, 'Proxima')[0]).toBe('Proxima Centauri');
|
expect(rank(entries, 'Proxima')[0]).toBe('Proxima Centauri');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lists a host's own planets ahead of the systems whose names run on from its own", () => {
|
||||||
|
const entries = [star('K2-18'), star('K2-180'), star('K2-181'), exoplanet('K2-18 b'), exoplanet('K2-18 c')];
|
||||||
|
expect(rank(entries, 'K2-18')).toEqual(['K2-18', 'K2-18 b', 'K2-18 c', 'K2-180', 'K2-181']);
|
||||||
|
});
|
||||||
|
|
||||||
it('breaks remaining ties by name length, then alphabetically', () => {
|
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')];
|
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']);
|
expect(rank(entries, 'Kepler')).toEqual(['Kepler-9 b', 'Kepler-9 c', 'Kepler-15 b', 'Kepler-1292 b']);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { spectralClassification } from '../../shared/astro/spectral';
|
||||||
|
|
||||||
export type SearchResultKind = 'star' | 'body' | 'exoplanet';
|
export type SearchResultKind = 'star' | 'body' | 'exoplanet';
|
||||||
|
|
||||||
export interface SearchEntry {
|
export interface SearchEntry {
|
||||||
@@ -6,22 +8,40 @@ export interface SearchEntry {
|
|||||||
subtitle: string;
|
subtitle: string;
|
||||||
/** HYG star id, for `kind: 'star'` results. */
|
/** HYG star id, for `kind: 'star'` results. */
|
||||||
starId?: number;
|
starId?: number;
|
||||||
|
/**
|
||||||
|
* The star behind a `kind: 'star'` entry, classified by {@link entrySubtitle} for the rows shown
|
||||||
|
* only. Classified up front, all 455 571 took 140-230 ms of the main thread at boot, for the
|
||||||
|
* route index, and again each time the search tab was opened.
|
||||||
|
*/
|
||||||
|
star?: Parameters<typeof spectralClassification>[0];
|
||||||
/** `bodies.json`/`exoplanets.json` id, for `kind: 'body' | 'exoplanet'` results. */
|
/** `bodies.json`/`exoplanets.json` id, for `kind: 'body' | 'exoplanet'` results. */
|
||||||
bodyId?: string;
|
bodyId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The line an entry is listed with: its subtitle, or a star's classification. */
|
||||||
|
export function entrySubtitle(entry: SearchEntry): string {
|
||||||
|
return entry.star ? spectralClassification(entry.star) : entry.subtitle;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How well a name matches, best first. The gaps are what matter: any exact match outranks every
|
* 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.
|
* 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_EXACT = 4;
|
||||||
|
/**
|
||||||
|
* A prefix that ends where a word does, "kepler-186 f" for "kepler-186", ahead of one running on into
|
||||||
|
* the same word, "kepler-1860". Level with it, the star outranked the planet on kind, and once the
|
||||||
|
* archive's hosts became stars, searching a host's name listed K2-180 to K2-186 and none of K2-18's
|
||||||
|
* planets: 325 hosts lost some of their own planets from the eight rows shown, and now 50 do.
|
||||||
|
*/
|
||||||
|
const MATCH_WORD_PREFIX = 3.5;
|
||||||
const MATCH_PREFIX = 3;
|
const MATCH_PREFIX = 3;
|
||||||
const MATCH_WORD_START = 2;
|
const MATCH_WORD_START = 2;
|
||||||
const MATCH_SUBSTRING = 1;
|
const MATCH_SUBSTRING = 1;
|
||||||
const NO_MATCH = 0;
|
const NO_MATCH = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Order for results that match equally well. Solar-system bodies are eighteen famous objects
|
* Order for results that match equally well. Solar-system bodies are a few dozen named worlds
|
||||||
* and win ties outright; a star outranks an exoplanet because searching a name like "Proxima"
|
* and win ties outright; a star outranks an exoplanet because searching a name like "Proxima"
|
||||||
* is usually an attempt to reach the system rather than one particular planet in it.
|
* is usually an attempt to reach the system rather than one particular planet in it.
|
||||||
*/
|
*/
|
||||||
@@ -76,7 +96,7 @@ function scoreIndexed(indexed: IndexedSearchEntry, normalizedQuery: string, comp
|
|||||||
return MATCH_EXACT;
|
return MATCH_EXACT;
|
||||||
}
|
}
|
||||||
if (indexed.normalizedName.startsWith(normalizedQuery)) {
|
if (indexed.normalizedName.startsWith(normalizedQuery)) {
|
||||||
return MATCH_PREFIX;
|
return WORD_SEPARATORS.test(indexed.normalizedName.charAt(normalizedQuery.length)) ? MATCH_WORD_PREFIX : MATCH_PREFIX;
|
||||||
}
|
}
|
||||||
if (indexed.words.some((word) => word.startsWith(normalizedQuery))) {
|
if (indexed.words.some((word) => word.startsWith(normalizedQuery))) {
|
||||||
return MATCH_WORD_START;
|
return MATCH_WORD_START;
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ function starRecord(id: number, name: string): StarRecord {
|
|||||||
/** Enough "Iot ..." stars to fill the result list ahead of the moon Io, as the real index does. */
|
/** Enough "Iot ..." stars to fill the result list ahead of the moon Io, as the real index does. */
|
||||||
const STARS: StarRecord[] = [
|
const STARS: StarRecord[] = [
|
||||||
...Array.from({ length: 12 }, (_, i) => starRecord(100 + i, `Iot Star ${i}`)),
|
...Array.from({ length: 12 }, (_, i) => starRecord(100 + i, `Iot Star ${i}`)),
|
||||||
starRecord(1, 'Proxima Centauri')
|
starRecord(1, 'Proxima Centauri'),
|
||||||
|
// As the ETL files a Gaia star: no type, a BP−RP colour; and a star with neither.
|
||||||
|
{ ...starRecord(2, 'TRAPPIST-1'), spectralType: 'Unknown', colorIndex: 4.902, colorSystem: 'BP-RP' },
|
||||||
|
{ ...starRecord(3, 'KMT-2016-BLG-1107L'), spectralType: 'Unknown', colorIndex: null }
|
||||||
];
|
];
|
||||||
|
|
||||||
const IO: BodyRecord = {
|
const IO: BodyRecord = {
|
||||||
@@ -34,7 +37,9 @@ const IO: BodyRecord = {
|
|||||||
argumentOfPeriapsisDeg: 0,
|
argumentOfPeriapsisDeg: 0,
|
||||||
meanAnomalyAtEpochDeg: 0,
|
meanAnomalyAtEpochDeg: 0,
|
||||||
epochJd: 2451545.0
|
epochJd: 2451545.0
|
||||||
}
|
},
|
||||||
|
rates: { meanMotionDegPerDay: 203.4889583, longitudeOfAscendingNodeDegPerDay: 0, argumentOfPeriapsisDegPerDay: 0 },
|
||||||
|
orbitSource: 'test'
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROXIMA_B: ExoplanetRecord = {
|
const PROXIMA_B: ExoplanetRecord = {
|
||||||
@@ -109,6 +114,17 @@ describe('SearchComponent', () => {
|
|||||||
expect(resultNames()).toContain('Proxima Cen b');
|
expect(resultNames()).toContain('Proxima Cen b');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lists a star by the type its colour gives it where it has none, and never as \"Unknown\"", async () => {
|
||||||
|
const kindLine = (): string => (element.querySelector('[data-testid="search-results"] button span:last-child')?.textContent ?? '').trim();
|
||||||
|
await type('TRAPPIST-1');
|
||||||
|
expect(kindLine()).toBe('Star · ~M8');
|
||||||
|
await type('KMT-2016-BLG-1107L');
|
||||||
|
expect(kindLine()).toBe('Star');
|
||||||
|
// Classified for the rows shown only: over all 455 571 stars, each opening of the tab spent 140-230 ms on it.
|
||||||
|
const index = (fixture.componentInstance as unknown as { index(): { entry: { kind: string; subtitle: string } }[] }).index();
|
||||||
|
expect(index.filter(({ entry }) => entry.kind === 'star').every(({ entry }) => entry.subtitle === '')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows nothing for a query that matches nothing', async () => {
|
it('shows nothing for a query that matches nothing', async () => {
|
||||||
await type('zzzzz');
|
await type('zzzzz');
|
||||||
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
|
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Router } from '@angular/router';
|
|||||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||||
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
|
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
|
||||||
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults, SearchEntry, SearchResultKind } from './search-ranking';
|
import { buildSearchIndex, entrySubtitle, IndexedSearchEntry, rankSearchResults, SearchEntry, SearchResultKind } from './search-ranking';
|
||||||
|
|
||||||
const MAX_RESULTS = 8;
|
const MAX_RESULTS = 8;
|
||||||
const MIN_QUERY_LENGTH = 2;
|
const MIN_QUERY_LENGTH = 2;
|
||||||
@@ -66,7 +66,7 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
|
|||||||
class="flex w-full items-baseline gap-3 border-l border-transparent px-3 py-2 text-left transition-colors hover:border-l-accent hover:bg-accent/8 focus-visible:border-l-accent focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
class="flex w-full items-baseline gap-3 border-l border-transparent px-3 py-2 text-left transition-colors hover:border-l-accent hover:bg-accent/8 focus-visible:border-l-accent focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
>
|
>
|
||||||
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ result.name }}</span>
|
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ result.name }}</span>
|
||||||
<span class="type-label max-w-[45%] shrink-0 truncate text-muted">{{ kindLabel(result.kind) }} · {{ result.subtitle }}</span>
|
<span class="type-label max-w-[45%] shrink-0 truncate text-muted">{{ kindLabel(result.kind) }}@if (result.subtitle) { · {{ result.subtitle }}}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,7 @@ export class SearchComponent {
|
|||||||
});
|
});
|
||||||
|
|
||||||
readonly matchTotal = computed(() => this.matches().length);
|
readonly matchTotal = computed(() => this.matches().length);
|
||||||
readonly results = computed(() => this.matches().slice(0, MAX_RESULTS));
|
readonly results = computed(() => this.matches().slice(0, MAX_RESULTS).map((entry) => ({ ...entry, subtitle: entrySubtitle(entry) })));
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dataLoader: DataLoaderService,
|
private readonly dataLoader: DataLoaderService,
|
||||||
@@ -153,7 +153,7 @@ export class SearchComponent {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const entries: SearchEntry[] = [
|
const entries: SearchEntry[] = [
|
||||||
...stars.map((star): SearchEntry => ({ kind: 'star', name: star.name, subtitle: star.spectralType, starId: star.id })),
|
...stars.map((star): SearchEntry => ({ kind: 'star', name: star.name, subtitle: '', star, starId: star.id })),
|
||||||
...bodies.map((body): SearchEntry => ({ kind: 'body', name: body.name, subtitle: body.kind, bodyId: body.id })),
|
...bodies.map((body): SearchEntry => ({ kind: 'body', name: body.name, subtitle: body.kind, bodyId: body.id })),
|
||||||
...exoplanets.map((exoplanet): SearchEntry => ({ kind: 'exoplanet', name: exoplanet.name, subtitle: exoplanet.hostStarName, bodyId: exoplanet.id }))
|
...exoplanets.map((exoplanet): SearchEntry => ({ kind: 'exoplanet', name: exoplanet.name, subtitle: exoplanet.hostStarName, bodyId: exoplanet.id }))
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { ExoplanetRecord } from '../models/exoplanet.model';
|
|||||||
import { appearanceForBody, appearanceForExoplanet, heliocentricDistanceAu } from './body-appearance';
|
import { appearanceForBody, appearanceForExoplanet, heliocentricDistanceAu } from './body-appearance';
|
||||||
import { DEFAULT_EPOCH_JD } from './constants';
|
import { DEFAULT_EPOCH_JD } from './constants';
|
||||||
|
|
||||||
|
const RATES = { meanMotionDegPerDay: 1, longitudeOfAscendingNodeDegPerDay: 0, argumentOfPeriapsisDegPerDay: 0 };
|
||||||
const ORBIT = { eccentricity: 0, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD };
|
const ORBIT = { eccentricity: 0, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD };
|
||||||
|
|
||||||
const JUPITER: BodyRecord = { id: 'jupiter', systemStarId: 0, name: 'Jupiter', kind: 'planet', radiusKm: 69911, orbit: { ...ORBIT, semiMajorAxisAu: 5.204 } };
|
const JUPITER: BodyRecord = { id: 'jupiter', systemStarId: 0, name: 'Jupiter', kind: 'planet', radiusKm: 69911, orbit: { ...ORBIT, semiMajorAxisAu: 5.204 }, rates: RATES, orbitSource: 'test' };
|
||||||
/** Europa's own orbit is around Jupiter: 671,000 km, which is 0.00449 AU. */
|
/** Europa's own orbit is around Jupiter: 671,000 km, which is 0.00449 AU. */
|
||||||
const EUROPA: BodyRecord = { id: 'europa', systemStarId: 0, name: 'Europa', kind: 'moon', radiusKm: 1560, parentBodyId: 'jupiter', orbit: { ...ORBIT, semiMajorAxisAu: 0.00449 } };
|
const EUROPA: BodyRecord = { id: 'europa', systemStarId: 0, name: 'Europa', kind: 'moon', radiusKm: 1560, parentBodyId: 'jupiter', orbit: { ...ORBIT, semiMajorAxisAu: 0.00449 }, rates: RATES, orbitSource: 'test' };
|
||||||
const EARTH: BodyRecord = { id: 'earth', systemStarId: 0, name: 'Earth', kind: 'planet', radiusKm: 6371, orbit: { ...ORBIT, semiMajorAxisAu: 1 } };
|
const EARTH: BodyRecord = { id: 'earth', systemStarId: 0, name: 'Earth', kind: 'planet', radiusKm: 6371, orbit: { ...ORBIT, semiMajorAxisAu: 1 }, rates: RATES, orbitSource: 'test' };
|
||||||
const ORPHAN: BodyRecord = { ...EUROPA, id: 'orphan', parentBodyId: 'nowhere' };
|
const ORPHAN: BodyRecord = { ...EUROPA, id: 'orphan', parentBodyId: 'nowhere' };
|
||||||
|
|
||||||
const BODIES = [JUPITER, EUROPA, EARTH, ORPHAN];
|
const BODIES = [JUPITER, EUROPA, EARTH, ORPHAN];
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { tdbFromUtc, ttMinusUtSeconds } from './constants';
|
||||||
|
|
||||||
|
const jd = (year: number, month = 1, day = 1): number => Date.UTC(year, month - 1, day) / 86400000 + 2440587.5;
|
||||||
|
|
||||||
|
describe('ttMinusUtSeconds', () => {
|
||||||
|
it('follows the historical record before 1972: within 1 per cent of Horizons at AD 1, 6 per cent at AD 1000, 0.2 s in 1950', () => {
|
||||||
|
// Horizons' TDB - UT (observer quantity 30) on JD 1721600, 2086455 and 2433282.5.
|
||||||
|
expect(Math.abs(ttMinusUtSeconds(1721600) - 10465.73)).toBeLessThan(105);
|
||||||
|
expect(Math.abs(ttMinusUtSeconds(2086455) - 1658.0)).toBeLessThan(100);
|
||||||
|
expect(Math.abs(ttMinusUtSeconds(2433282.5) - 28.93)).toBeLessThan(0.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the leap seconds from 1972, and holds the last from 2017 on', () => {
|
||||||
|
expect(ttMinusUtSeconds(jd(1972, 6, 30))).toBe(42.184);
|
||||||
|
expect(ttMinusUtSeconds(jd(1972, 7, 1))).toBe(43.184);
|
||||||
|
expect(ttMinusUtSeconds(jd(2016, 12, 31))).toBe(68.184);
|
||||||
|
expect(ttMinusUtSeconds(jd(2017, 1, 1))).toBe(69.184);
|
||||||
|
expect(ttMinusUtSeconds(jd(2999, 1, 1))).toBe(69.184);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins its polynomials without a jump of more than 0.3 s, the 0.25 s at 1600 the worst', () => {
|
||||||
|
for (const year of [500, 1600, 1700, 1800, 1860, 1900, 1920, 1941, 1961]) {
|
||||||
|
const at = 2451544.5 + (year - 2000) * 365.2425;
|
||||||
|
expect(Math.abs(ttMinusUtSeconds(at + 0.01) - ttMinusUtSeconds(at - 0.01))).toBeLessThan(0.3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands over from the polynomial to the leap seconds at the start of 1972, 0.07 s apart', () => {
|
||||||
|
// The switch is placed by the calendar, not by a 365.2425-day year, so it is sampled on either
|
||||||
|
// side of midnight; the last day of 1971 must still be the polynomial's 42.25 s, not the table's
|
||||||
|
// 42.184, or the switch has moved earlier; and midnight itself must already be the table's, or it
|
||||||
|
// has moved later, which the step alone cannot see once both samples fall on the polynomial.
|
||||||
|
const start = jd(1972);
|
||||||
|
expect(ttMinusUtSeconds(start)).toBe(42.184);
|
||||||
|
expect(Math.abs(ttMinusUtSeconds(start) - ttMinusUtSeconds(start - 1e-6))).toBeLessThan(0.1);
|
||||||
|
expect(Math.abs(ttMinusUtSeconds(jd(1971, 12, 31)) - 42.2485)).toBeLessThan(0.01);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tdbFromUtc', () => {
|
||||||
|
it('puts the clock’s date that far on', () => {
|
||||||
|
expect((tdbFromUtc(jd(2025)) - jd(2025)) * 86400).toBeCloseTo(69.184, 3);
|
||||||
|
expect((tdbFromUtc(2086455) - 2086455) * 86400).toBeCloseTo(ttMinusUtSeconds(2086455), 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,31 +19,84 @@ export const DEFAULT_EPOCH_JD = 2451545.0;
|
|||||||
*/
|
*/
|
||||||
export const GM_SUN_AU3_PER_DAY2 = 0.01720209895 * 0.01720209895;
|
export const GM_SUN_AU3_PER_DAY2 = 0.01720209895 * 0.01720209895;
|
||||||
|
|
||||||
/**
|
/** The first day of each month UTC took a leap second at the start of, from its 10 s of 1972. */
|
||||||
* Approximate planet/Sun mass ratios for the major planets that host moons in `bodies.json`.
|
const LEAP_SECONDS_FROM = [
|
||||||
* Used to derive each planet's gravitational parameter (for propagating its moons) as
|
[1972, 7], [1973, 1], [1974, 1], [1975, 1], [1976, 1], [1977, 1], [1978, 1], [1979, 1], [1980, 1], [1981, 7],
|
||||||
* `GM_SUN_AU3_PER_DAY2 * massRatio`. Precise enough for visualization; not JPL-grade.
|
[1982, 7], [1983, 7], [1985, 7], [1988, 1], [1990, 1], [1991, 1], [1992, 7], [1993, 7], [1994, 7], [1996, 1],
|
||||||
*/
|
[1997, 7], [1999, 1], [2006, 1], [2009, 1], [2012, 7], [2015, 7], [2017, 1]
|
||||||
const PLANET_TO_SUN_MASS_RATIO: Record<string, number> = {
|
].map(([year, month]) => Date.UTC(year, month - 1, 1) / 86400000 + 2440587.5);
|
||||||
earth: 3.003e-6,
|
const JD_1972 = Date.UTC(1972, 0, 1) / 86400000 + 2440587.5;
|
||||||
mars: 3.227e-7,
|
|
||||||
jupiter: 9.545e-4,
|
|
||||||
saturn: 2.857e-4,
|
|
||||||
uranus: 4.365e-5,
|
|
||||||
neptune: 5.151e-5
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gravitational parameter (AU^3/day^2) to use when propagating a body's orbit: the Sun's
|
* TT - UT, in seconds, at a date on the map's clock: how far Earth's turning, which UT counts,
|
||||||
* for planets/dwarfs/exoplanets, or the host planet's (derived from its Sun mass ratio) for
|
* has fallen behind the uniform time the ephemerides run on.
|
||||||
* moons. Falls back to the Sun's GM if `parentBodyId` isn't a known planet.
|
*
|
||||||
|
* From 1972 the clock is UTC, held to within 0.9 s of UT by leap seconds, and TT - UTC is exact:
|
||||||
|
* 32.184 s plus TAI - UTC, which is the 10 s UTC started from in 1972 and the 27 leap seconds taken
|
||||||
|
* since, 37 s from 2017. After the last, at the start of 2017, it is held at 69.184 s, as Horizons
|
||||||
|
* holds it: no one knows the leap seconds to come. Before 1972 it is ΔT from the Espenak-Meeus
|
||||||
|
* polynomials (NASA's Five Millennium Canon, 2006), which fit the historical record of eclipses and
|
||||||
|
* occultations: 10 570 s at AD 1, 1 574 at AD 1000, 29 in 1950. As published they join within
|
||||||
|
* 0.26 s (at 1600; 0.16 s at 1700, under 0.09 s elsewhere), and the last meets the leap-second
|
||||||
|
* table 0.07 s apart. Held at 69 s there, as it was, every spin but Earth's was a turn of
|
||||||
|
* (ΔT - 69 s) times its rate out, 15 degrees for Jupiter at AD 1000 and 106 at AD 1, and the Moon
|
||||||
|
* 0.21 to 0.26 and 1.44 to 1.79 degrees along its orbit, as its eccentric orbit carries it faster
|
||||||
|
* or slower through those hours.
|
||||||
*/
|
*/
|
||||||
export function gmForParent(parentBodyId: string | undefined): number {
|
export function ttMinusUtSeconds(jdUt: number): number {
|
||||||
if (!parentBodyId) {
|
if (jdUt >= JD_1972) {
|
||||||
return GM_SUN_AU3_PER_DAY2;
|
return 32.184 + 10 + LEAP_SECONDS_FROM.filter((from) => jdUt >= from).length;
|
||||||
}
|
}
|
||||||
const massRatio = PLANET_TO_SUN_MASS_RATIO[parentBodyId];
|
const y = 2000 + (jdUt - 2451544.5) / 365.2425;
|
||||||
return massRatio ? GM_SUN_AU3_PER_DAY2 * massRatio : GM_SUN_AU3_PER_DAY2;
|
if (y < 500) {
|
||||||
|
const u = y / 100;
|
||||||
|
return 10583.6 - 1014.41 * u + 33.78311 * u ** 2 - 5.952053 * u ** 3 - 0.1798452 * u ** 4 + 0.022174192 * u ** 5 + 0.0090316521 * u ** 6;
|
||||||
|
}
|
||||||
|
if (y < 1600) {
|
||||||
|
const u = (y - 1000) / 100;
|
||||||
|
return 1574.2 - 556.01 * u + 71.23472 * u ** 2 + 0.319781 * u ** 3 - 0.8503463 * u ** 4 - 0.005050998 * u ** 5 + 0.0083572073 * u ** 6;
|
||||||
|
}
|
||||||
|
if (y < 1700) {
|
||||||
|
const t = y - 1600;
|
||||||
|
return 120 - 0.9808 * t - 0.01532 * t ** 2 + t ** 3 / 7129;
|
||||||
|
}
|
||||||
|
if (y < 1800) {
|
||||||
|
const t = y - 1700;
|
||||||
|
return 8.83 + 0.1603 * t - 0.0059285 * t ** 2 + 0.00013336 * t ** 3 - t ** 4 / 1174000;
|
||||||
|
}
|
||||||
|
if (y < 1860) {
|
||||||
|
const t = y - 1800;
|
||||||
|
return 13.72 - 0.332447 * t + 0.0068612 * t ** 2 + 0.0041116 * t ** 3 - 0.00037436 * t ** 4 + 0.0000121272 * t ** 5 - 0.0000001699 * t ** 6 + 0.000000000875 * t ** 7;
|
||||||
|
}
|
||||||
|
if (y < 1900) {
|
||||||
|
const t = y - 1860;
|
||||||
|
return 7.62 + 0.5737 * t - 0.251754 * t ** 2 + 0.01680668 * t ** 3 - 0.0004473624 * t ** 4 + t ** 5 / 233174;
|
||||||
|
}
|
||||||
|
if (y < 1920) {
|
||||||
|
const t = y - 1900;
|
||||||
|
return -2.79 + 1.494119 * t - 0.0598939 * t ** 2 + 0.0061966 * t ** 3 - 0.000197 * t ** 4;
|
||||||
|
}
|
||||||
|
if (y < 1941) {
|
||||||
|
const t = y - 1920;
|
||||||
|
return 21.2 + 0.84493 * t - 0.0761 * t ** 2 + 0.0020936 * t ** 3;
|
||||||
|
}
|
||||||
|
if (y < 1961) {
|
||||||
|
const t = y - 1950;
|
||||||
|
return 29.07 + 0.407 * t - t ** 2 / 233 + t ** 3 / 2547;
|
||||||
|
}
|
||||||
|
const t = y - 1975;
|
||||||
|
return 45.45 + 1.067 * t - t ** 2 / 260 - t ** 3 / 718;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The TDB date every element set here is evaluated at, for a date on the map's clock, which is
|
||||||
|
* UT: Standish's T_eph, the SSD satellite and SBDB epochs and the IAU's d and T all run on TDB.
|
||||||
|
* Positions and spins both go through this, so a locked moon's face and the orbit it is drawn on
|
||||||
|
* are taken at the same instant; taken at the clock's date, the orbits ran 69 s behind the spins,
|
||||||
|
* which is 0.9 degrees of Phobos's orbit and 0.16 of Io's.
|
||||||
|
*/
|
||||||
|
export function tdbFromUtc(jdUtc: number): number {
|
||||||
|
return jdUtc + ttMinusUtSeconds(jdUtc) / 86400;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Converts a JS `Date` into a Julian date (days), for driving the Kepler propagator "now". */
|
/** Converts a JS `Date` into a Julian date (days), for driving the Kepler propagator "now". */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
distanceBetween,
|
distanceBetween,
|
||||||
eclipticToEquatorial,
|
eclipticToEquatorial,
|
||||||
equatorialToEcliptic,
|
equatorialToEcliptic,
|
||||||
|
laplacePlaneToEquatorial,
|
||||||
OBLIQUITY_J2000_DEG,
|
OBLIQUITY_J2000_DEG,
|
||||||
parallaxMasToParsecs,
|
parallaxMasToParsecs,
|
||||||
parseSexagesimal,
|
parseSexagesimal,
|
||||||
@@ -206,6 +207,26 @@ describe('eclipticToEquatorial', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('laplacePlaneToEquatorial', () => {
|
||||||
|
const RAD = Math.PI / 180;
|
||||||
|
/** Jupiter's moons' Laplace pole, as JPL gives it for Io. */
|
||||||
|
const POLE = { raDeg: 268.057, decDeg: 64.495 };
|
||||||
|
|
||||||
|
it('sends the plane’s own pole to the right ascension and declination it is named by', () => {
|
||||||
|
const pole = laplacePlaneToEquatorial({ x: 0, y: 0, z: 1 }, POLE);
|
||||||
|
expect(Math.asin(pole.z) / RAD).toBeCloseTo(POLE.decDeg, 9);
|
||||||
|
expect(((Math.atan2(pole.y, pole.x) / RAD) + 360) % 360).toBeCloseTo(POLE.raDeg, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the node from where the plane rises through the equator, 90 degrees past the pole', () => {
|
||||||
|
const node = laplacePlaneToEquatorial({ x: 1, y: 0, z: 0 }, POLE);
|
||||||
|
expect(node.z).toBeCloseTo(0, 12);
|
||||||
|
expect(((Math.atan2(node.y, node.x) / RAD) + 360) % 360).toBeCloseTo((POLE.raDeg + 90) % 360, 9);
|
||||||
|
// Rising: a quarter-turn on along the plane is north of the equator.
|
||||||
|
expect(laplacePlaneToEquatorial({ x: 0, y: 1, z: 0 }, POLE).z).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('equatorialToEcliptic', () => {
|
describe('equatorialToEcliptic', () => {
|
||||||
it('is the exact inverse of eclipticToEquatorial', () => {
|
it('is the exact inverse of eclipticToEquatorial', () => {
|
||||||
for (const point of [
|
for (const point of [
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ export const OBLIQUITY_J2000_DEG = 23.4392911;
|
|||||||
*
|
*
|
||||||
* The app has to span both because its two sources disagree. Star positions come from HYG as
|
* The app has to span both because its two sources disagree. Star positions come from HYG as
|
||||||
* equatorial coordinates, which `raDecDistanceToXyz` produces and which the galaxy view renders
|
* equatorial coordinates, which `raDecDistanceToXyz` produces and which the galaxy view renders
|
||||||
* directly. Orbital elements come from JPL Horizons, whose default reference plane for element
|
* directly. The planets' and the Moon's orbital elements are JPL mean elements against the J2000
|
||||||
* output is the ecliptic — the ETL never overrides it. The two are tilted
|
* ecliptic. The two are tilted
|
||||||
* {@link OBLIQUITY_J2000_DEG} apart about the shared vernal-equinox axis, so orbits have to be
|
* {@link OBLIQUITY_J2000_DEG} apart about the shared vernal-equinox axis, so orbits have to be
|
||||||
* rotated before they can share a scene with the stars.
|
* rotated before they can share a scene with the stars.
|
||||||
*/
|
*/
|
||||||
@@ -78,6 +78,29 @@ export function eclipticToEquatorial(position: CartesianCoordinates): CartesianC
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotates a vector from a moon's local **Laplace plane** frame into the equatorial one.
|
||||||
|
*
|
||||||
|
* JPL gives the giant planets' moons against the plane their orbits precess about, which lies
|
||||||
|
* between the planet's equator and its orbit, and names it by its pole. The frame's x axis is
|
||||||
|
* where that plane rises through the ICRF equator, at right ascension 90 degrees past the pole's,
|
||||||
|
* which is what the node is counted from; its z axis is the pole, 90 degrees less its declination
|
||||||
|
* away from the celestial one. Read against the ecliptic instead, Io was up to 2.8 degrees from
|
||||||
|
* where Horizons has it between 1950 and 2100, Phobos 54 and Titan 127: their nodes are counted
|
||||||
|
* from a different line altogether.
|
||||||
|
*/
|
||||||
|
export function laplacePlaneToEquatorial(position: CartesianCoordinates, pole: { raDeg: number; decDeg: number }): CartesianCoordinates {
|
||||||
|
const tilt = (90 - pole.decDeg) * DEG_TO_RAD;
|
||||||
|
const node = (pole.raDeg + 90) * DEG_TO_RAD;
|
||||||
|
const y = position.y * Math.cos(tilt) - position.z * Math.sin(tilt);
|
||||||
|
const z = position.y * Math.sin(tilt) + position.z * Math.cos(tilt);
|
||||||
|
return {
|
||||||
|
x: position.x * Math.cos(node) - y * Math.sin(node),
|
||||||
|
y: position.x * Math.sin(node) + y * Math.cos(node),
|
||||||
|
z
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Inverse of {@link eclipticToEquatorial}. */
|
/** Inverse of {@link eclipticToEquatorial}. */
|
||||||
export function equatorialToEcliptic(position: CartesianCoordinates): CartesianCoordinates {
|
export function equatorialToEcliptic(position: CartesianCoordinates): CartesianCoordinates {
|
||||||
const obliquity = OBLIQUITY_J2000_DEG * DEG_TO_RAD;
|
const obliquity = OBLIQUITY_J2000_DEG * DEG_TO_RAD;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe('classifyOpenNgcType', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('groups nebulae, remnants and cluster-with-nebulosity as nebulae', () => {
|
it('groups nebulae, remnants and cluster-with-nebulosity as nebulae', () => {
|
||||||
for (const type of ['PN', 'HII', 'EmN', 'RfN', 'Neb', 'DrkN', 'SNR', 'Cl+N']) {
|
for (const type of ['PN', 'HII', 'EmN', 'RfN', 'Neb', 'SNR', 'Cl+N']) {
|
||||||
expect(classifyOpenNgcType(type)).toBe('nebula');
|
expect(classifyOpenNgcType(type)).toBe('nebula');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -36,6 +36,10 @@ describe('classifyOpenNgcType', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leaves out a dark nebula, which a sprite that adds light cannot draw', () => {
|
||||||
|
expect(classifyOpenNgcType('DrkN')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects missing or unknown types', () => {
|
it('rejects missing or unknown types', () => {
|
||||||
for (const type of ['', ' ', 'wat', undefined, null]) {
|
for (const type of ['', ' ', 'wat', undefined, null]) {
|
||||||
expect(classifyOpenNgcType(type)).toBeNull();
|
expect(classifyOpenNgcType(type)).toBeNull();
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ const MAX_PARALLAX_DISTANCE_PC = 100000;
|
|||||||
/**
|
/**
|
||||||
* OpenNGC object-type codes grouped into the three kinds the backdrop distinguishes.
|
* OpenNGC object-type codes grouped into the three kinds the backdrop distinguishes.
|
||||||
* Codes not listed here (`Dup` duplicates, `NonEx` non-existent entries, plain stars `*`,
|
* Codes not listed here (`Dup` duplicates, `NonEx` non-existent entries, plain stars `*`,
|
||||||
* doubles `**`, `Nova`, `Other`) are not deep-sky objects and are dropped.
|
* doubles `**`, `Nova`, `Other`) are not deep-sky objects and are dropped. Nor is `DrkN`, a dark
|
||||||
|
* nebula, drawn: it is dust in front of the light behind it, and the backdrop's sprites can only
|
||||||
|
* add light — the Coalsack and the Horsehead, which OpenNGC's addendum brought in, glowed pink
|
||||||
|
* where the sky has a hole.
|
||||||
*/
|
*/
|
||||||
const KIND_BY_OPENNGC_TYPE: Readonly<Record<string, DeepSkyKind>> = {
|
const KIND_BY_OPENNGC_TYPE: Readonly<Record<string, DeepSkyKind>> = {
|
||||||
// Galaxies, and multi-galaxy systems.
|
// Galaxies, and multi-galaxy systems.
|
||||||
@@ -52,7 +55,6 @@ const KIND_BY_OPENNGC_TYPE: Readonly<Record<string, DeepSkyKind>> = {
|
|||||||
EmN: 'nebula',
|
EmN: 'nebula',
|
||||||
RfN: 'nebula',
|
RfN: 'nebula',
|
||||||
Neb: 'nebula',
|
Neb: 'nebula',
|
||||||
DrkN: 'nebula',
|
|
||||||
SNR: 'nebula',
|
SNR: 'nebula',
|
||||||
'Cl+N': 'nebula',
|
'Cl+N': 'nebula',
|
||||||
// Star clusters and associations.
|
// Star clusters and associations.
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { extractGmKm3PerS2, extractObliquityDeg, extractRadiusKm, extractRotationPeriodHours, isTidallyLocked } from './horizons-page';
|
||||||
|
|
||||||
|
// Lines as the Horizons pages print them.
|
||||||
|
const JUPITER = ` Vol. Mean Radius (km) = 69911+-6 Flattening = 0.06487
|
||||||
|
Sid. rot. period (III)= 9h 55m 29.711 s Sid. rot. rate (rad/s)= 0.00017585`;
|
||||||
|
const MIRANDA = ` Radius (km) = 240x234.2x232.9 Density (g cm^-3) = 1.18 +- 0.05
|
||||||
|
GM (km^3/s^2) = 4.3 +- 0.2 Geometric Albedo = 0.27
|
||||||
|
Eccentricity, e = 0.0027 Rotational period = Synchronous`;
|
||||||
|
const CHARON = ` GM (km^3/s^2) = 106.10 +- 0.3 Density (g cm^-3) = 1.853 +- 0.004
|
||||||
|
Radius (km, IAU2015) = 606 +- 0.5 Geometric albedo = `;
|
||||||
|
const PLUTO = ` GM (planet) km^3/s^2 = 869.326 Density (R=1195 km) = 1.86 g/cm^3
|
||||||
|
Vol. mean radius (km) = 1188.3+-1.6 Mass ratio (Mc/Mp) = 0.122`;
|
||||||
|
const PHOEBE = ` Radius (km) = 106.6 +- 1.1 Density (g/cm^3)= 1.633 +- 0.049
|
||||||
|
Eccentricity, e = 0.1635 Rotational period = 9h 16.438 m`;
|
||||||
|
const HYPERION = ` Mean Radius (km) = 133 +- 8 Density (g/cm^3) = 0.569 +- 0.108
|
||||||
|
Eccentricity, e = 0.0232 Rotational period = Chaotic`;
|
||||||
|
|
||||||
|
describe('Horizons page radius', () => {
|
||||||
|
it('reads a volumetric mean radius', () => {
|
||||||
|
expect(extractRadiusKm(PLUTO)).toBe(1188.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the radius Charon states against IAU 2015', () => {
|
||||||
|
expect(extractRadiusKm(CHARON)).toBe(606);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a triaxial body the radius of the sphere of its volume, not its longest axis', () => {
|
||||||
|
// (240 × 234.2 × 232.9)^(1/3); the IAU's mean radius for Miranda is 235.8.
|
||||||
|
expect(extractRadiusKm(MIRANDA)).toBeCloseTo(235.7, 1);
|
||||||
|
// Phobos spaces its axes out; it was drawn at its longest, 13.1 km, against the IAU's 11.08.
|
||||||
|
expect(extractRadiusKm(' Radius (km) = 13.1 x11.1 x9.3 Density (g cm^-3) = 1.90')).toBeCloseTo(11.06, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Horizons page rotation', () => {
|
||||||
|
it('reads hours, minutes and seconds', () => {
|
||||||
|
expect(extractRotationPeriodHours(JUPITER)).toBeCloseTo(9.925, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads hours and minutes, as Phoebe states them', () => {
|
||||||
|
expect(extractRotationPeriodHours(PHOEBE)).toBeCloseTo(9 + 16.438 / 60, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds no period where the spin is chaotic', () => {
|
||||||
|
expect(extractRotationPeriodHours(HYPERION)).toBeUndefined();
|
||||||
|
expect(isTidallyLocked(HYPERION)).toBe(false);
|
||||||
|
expect(isTidallyLocked(MIRANDA)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Horizons page GM', () => {
|
||||||
|
it('reads a moon’s GM and Pluto’s, which are written differently', () => {
|
||||||
|
expect(extractGmKm3PerS2(CHARON)).toBe(106.1);
|
||||||
|
expect(extractGmKm3PerS2(PLUTO)).toBe(869.326);
|
||||||
|
expect(extractGmKm3PerS2(HYPERION)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Horizons page obliquity', () => {
|
||||||
|
it('reads the arcminutes Mercury gives its tilt in, and the degrees every other page uses', () => {
|
||||||
|
expect(extractObliquityDeg(" Obliquity to orbit[1] = 2.11' +/- 0.1' Hill's sphere rad. Rp = 94.4 ")).toBeCloseTo(2.11 / 60, 9);
|
||||||
|
expect(extractObliquityDeg(' Obliquity to orbit = 25.19 deg Max. angular diam. = 17.9"')).toBe(25.19);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Reads the physical-data block at the top of a JPL Horizons object page, which the ETL fetches
|
||||||
|
* (see `tools/etl/lib/horizons.ts`). Every page is written by hand, so each quantity is stated in
|
||||||
|
* several ways; the patterns below are the ones the bodies in `bodies.json` actually use.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What follows the `=`: one radius, or a triaxial body's three semi-axes as `240x234.2x232.9`,
|
||||||
|
* as Miranda's and Ariel's pages give them.
|
||||||
|
*/
|
||||||
|
const RADIUS_VALUE = String.raw`=\s*([\d.]+(?:\s*x\s*[\d.]+)*)`;
|
||||||
|
const RADIUS_PATTERNS = [
|
||||||
|
new RegExp(String.raw`Vol\.?\s*mean\s*radius[^=]*${RADIUS_VALUE}`, 'i'),
|
||||||
|
new RegExp(String.raw`Mean\s*radius[^=]*${RADIUS_VALUE}`, 'i'),
|
||||||
|
new RegExp(String.raw`Radius\s*\(IAU\)[^=]*${RADIUS_VALUE}`, 'i'),
|
||||||
|
// Charon's page says `Radius (km, IAU2015) = 606`.
|
||||||
|
new RegExp(String.raw`Radius,?\s*\(km(?:,\s*IAU\s*2015)?\)\s*${RADIUS_VALUE}`, 'i'),
|
||||||
|
new RegExp(String.raw`Radius\s*\(gravity\),?\s*km\s*${RADIUS_VALUE}`, 'i')
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How each page states how fast the body turns, in the order they are tried.
|
||||||
|
*
|
||||||
|
* The rate in radians per second is preferred wherever it appears: it is unambiguous and it is
|
||||||
|
* signed: Venus and Uranus carry a negative one. A period in hours and minutes comes next, as the
|
||||||
|
* giant planets and Phoebe state it, then one in hours or days, and finally the word most moons
|
||||||
|
* carry instead of a number, Synchronous. Not all do — the Moon's page gives a rate, Titan's
|
||||||
|
* nothing — so the caller treats every moon it lists as locked whatever its page says.
|
||||||
|
*/
|
||||||
|
const ROTATION_RATE_PATTERN = /Rot(?:ational)?\.?\s*Rate\s*[(,]\s*rad\/s\s*\)?\s*=\s*(-?[\d.]+)/i;
|
||||||
|
/**
|
||||||
|
* `9h 55m 29.711 s`, as Jupiter and Saturn state it, and `9h 16.438 m`, as Phoebe does: read as
|
||||||
|
* a period in hours alone, Phoebe turned once in 9 hours instead of 9.274.
|
||||||
|
*/
|
||||||
|
const SEXAGESIMAL_ROTATION_PATTERN = /(?:Sid(?:ereal|\.)?\s*rot\.?|Rotation(?:al)?)\s*period[^=]*=\s*(\d+)\s*h\s*([\d.]+)\s*m(?:\s*([\d.]+)\s*s)?/i;
|
||||||
|
const ROTATION_PERIOD_PATTERNS = [
|
||||||
|
/Sid(?:ereal|\.)?\s*rot\.?\s*period[^=]*=\s*(-?[\d.]+)(?:\+-[\d.]+)?\s*(h|hr|hrs|d|day|days)\b/i,
|
||||||
|
/Rotation(?:al)?\s*period[^=]*=\s*(-?[\d.]+)\s*(h|hr|hrs|d|day|days)\b/i
|
||||||
|
];
|
||||||
|
const SYNCHRONOUS_PATTERN = /Rotation(?:al)?\s*period\s*=?\s*:?\s*Synchronous/i;
|
||||||
|
/** `25.19 deg` on most pages, `2.11' +/- 0.1'` in arcminutes on Mercury's. */
|
||||||
|
const OBLIQUITY_PATTERN = /Obliquity\s*to\s*orbit[^=]*=\s*(-?[\d.]+)\s*(')?/i;
|
||||||
|
/** `GM (km^3/s^2) = 106.10` on a moon's page, `GM (planet) km^3/s^2 = 869.326` on Pluto's. */
|
||||||
|
const GM_PATTERN = /GM\s*(?:\(planet\)\s*)?,?\s*\(?km\^3\/s\^2\)?\s*=\s*([\d.]+)/i;
|
||||||
|
|
||||||
|
const HOURS_PER_DAY = 24;
|
||||||
|
const SECONDS_PER_HOUR = 3600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True where the page gives no number because the body keeps one face to its parent, so its day
|
||||||
|
* is its orbit. The period itself is then the orbit's, which the caller takes from the body's
|
||||||
|
* mean motion.
|
||||||
|
*/
|
||||||
|
export function isTidallyLocked(text: string): boolean {
|
||||||
|
return SYNCHRONOUS_PATTERN.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sidereal rotation period, in hours, from whichever form the page states it in. */
|
||||||
|
export function extractRotationPeriodHours(text: string): number | undefined {
|
||||||
|
const rate = text.match(ROTATION_RATE_PATTERN);
|
||||||
|
if (rate && Number(rate[1]) !== 0) {
|
||||||
|
return (2 * Math.PI) / (Number(rate[1]) * SECONDS_PER_HOUR);
|
||||||
|
}
|
||||||
|
const sexagesimal = text.match(SEXAGESIMAL_ROTATION_PATTERN);
|
||||||
|
if (sexagesimal) {
|
||||||
|
return Number(sexagesimal[1]) + Number(sexagesimal[2]) / 60 + Number(sexagesimal[3] ?? 0) / SECONDS_PER_HOUR;
|
||||||
|
}
|
||||||
|
for (const pattern of ROTATION_PERIOD_PATTERNS) {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
const hours = Number(match[1]) * (match[2].toLowerCase().startsWith('d') ? HOURS_PER_DAY : 1);
|
||||||
|
return Number.isFinite(hours) && hours !== 0 ? hours : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tilt of the rotation axis from the orbit, in degrees. Mercury's page gives its tilt in
|
||||||
|
* arcminutes, which read as degrees made it 2.11 where the IAU's pole puts it at 0.034.
|
||||||
|
*/
|
||||||
|
export function extractObliquityDeg(text: string): number | undefined {
|
||||||
|
const match = text.match(OBLIQUITY_PATTERN);
|
||||||
|
return match ? Number(match[1]) / (match[2] ? 60 : 1) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mean radius in km. For a triaxial body, the radius of the sphere of the same volume, the cube
|
||||||
|
* root of the three semi-axes' product, which is how the IAU states a mean radius: Miranda's
|
||||||
|
* 240 x 234.2 x 232.9 km is 235.7, where the first figure alone overstated it by 2 per cent.
|
||||||
|
*/
|
||||||
|
export function extractRadiusKm(text: string): number | undefined {
|
||||||
|
for (const pattern of RADIUS_PATTERNS) {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
const axes = match[1].split('x').map(Number);
|
||||||
|
return axes.reduce((product, axis) => product * axis, 1) ** (1 / axes.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The body's own GM, in km³/s², where the page publishes one. */
|
||||||
|
export function extractGmKm3PerS2(text: string): number | undefined {
|
||||||
|
const match = text.match(GM_PATTERN);
|
||||||
|
return match ? Number(match[1]) : undefined;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { buildStarNameIndex, normalizeStarName, resolveHostStarId } from './host-star-matching';
|
import { ARCHIVE_EPOCH, ARCHIVE_ID_BASE, archiveDistancePc, archiveStarId, buildStarNameIndex, CATALOGUE_EPOCH, normalizeStarName, resolveHostStarId } from './host-star-matching';
|
||||||
import { propagateProperMotion, raDegDecDistanceToXyz } from './coordinates';
|
import { propagateProperMotion, raDegDecDistanceToXyz } from './coordinates';
|
||||||
import { StarRecord } from '../models/star.model';
|
import { StarRecord } from '../models/star.model';
|
||||||
|
|
||||||
@@ -81,13 +81,13 @@ describe('resolveHostStarId', () => {
|
|||||||
expect(id).toBe(80);
|
expect(id).toBe(80);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GJ 15 A's archive row sits at J2016, 46″ along its proper motion from Groombridge 34's
|
// GJ 15 A's archive row sits at J2015.5, 45″ along its proper motion from Groombridge 34's
|
||||||
// J2000 place — and only 16″ from an unrelated Gaia entry. Nearest-to-the-published-point
|
// J2000 place — and only 16″ from an unrelated Gaia entry. Nearest-to-the-published-point
|
||||||
// picks the interloper; carrying the query back the sixteen years must put the planets on
|
// picks the interloper; carrying the query back the fifteen and a half years must put the
|
||||||
// the star that actually moved there.
|
// planets on the star that actually moved there.
|
||||||
it('picks the star the proper motion says the query is, not the entry nearest the published point', () => {
|
it('picks the star the proper motion says the query is, not the entry nearest the published point', () => {
|
||||||
const primary = star(90, 'Groombridge 34', 4.595364, 44.022955, 3.562);
|
const primary = star(90, 'Groombridge 34', 4.595364, 44.022955, 3.562);
|
||||||
const published = propagateProperMotion(4.595364, 44.022955, 2891.5, 411.9, 16);
|
const published = propagateProperMotion(4.595364, 44.022955, 2891.5, 411.9, 15.5);
|
||||||
const interloper = star(91, 'Gaia DR3 385334196532776576', published.raDeg, published.decDeg + 16 / 3600, 3.563);
|
const interloper = star(91, 'Gaia DR3 385334196532776576', published.raDeg, published.decDeg + 16 / 3600, 3.563);
|
||||||
|
|
||||||
const id = resolveHostStarId(
|
const id = resolveHostStarId(
|
||||||
@@ -109,6 +109,17 @@ describe('resolveHostStarId', () => {
|
|||||||
expect(id).toBeNull();
|
expect(id).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The archive's 5.92 pc is TICv8's; its own parallax, 263.26 mas, says 3.80 pc, which is the
|
||||||
|
// star's. With the parallax given, the same query is Luyten's Star — and a parallax that
|
||||||
|
// contradicts the star as well rescues nothing.
|
||||||
|
it("accepts the archive's parallax where its sy_dist contradicts the star", () => {
|
||||||
|
const luytens = star(100, "Luyten's Star", 111.8496, 5.2258, 3.79);
|
||||||
|
const query = { hostname: 'GJ 273', raDeg: 111.8496, decDeg: 5.2258, distancePc: 5.921535 };
|
||||||
|
|
||||||
|
expect(resolveHostStarId({ ...query, parallaxMas: 263.26 }, [luytens])).toBe(100);
|
||||||
|
expect(resolveHostStarId({ ...query, parallaxMas: 168.9 }, [luytens])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
// The tolerance is transverse — parsecs on the sky, not an angle — so the same 15″ offset
|
// The tolerance is transverse — parsecs on the sky, not an angle — so the same 15″ offset
|
||||||
// is a match at 50 pc and a stranger at 200 pc.
|
// is a match at 50 pc and a stranger at 200 pc.
|
||||||
it('scales the angular tolerance with the host distance', () => {
|
it('scales the angular tolerance with the host distance', () => {
|
||||||
@@ -196,6 +207,14 @@ describe('resolveHostStarId', () => {
|
|||||||
expect(id).toBe(1);
|
expect(id).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("takes the archive's parallax where it gives no distance, and nothing from a parallax that is none", () => {
|
||||||
|
// mu2 Sco: sy_dist blank, sy_plx 6.31 mas; the catalogue has Pipirima at 145.3 pc.
|
||||||
|
expect(archiveDistancePc(undefined, 6.31)).toBeCloseTo(158.48, 2);
|
||||||
|
expect(archiveDistancePc(145.35, 6.31)).toBe(145.35);
|
||||||
|
expect(archiveDistancePc(undefined, 0)).toBeNaN();
|
||||||
|
expect(archiveDistancePc(undefined, undefined)).toBeNaN();
|
||||||
|
});
|
||||||
|
|
||||||
it('lets a named host resolve even with no usable distance', () => {
|
it('lets a named host resolve even with no usable distance', () => {
|
||||||
const id = resolveHostStarId({ hostname: 'Sirius', raDeg: 101.3, decDeg: -16.7, distancePc: 0 }, FIXTURE_STARS);
|
const id = resolveHostStarId({ hostname: 'Sirius', raDeg: 101.3, decDeg: -16.7, distancePc: 0 }, FIXTURE_STARS);
|
||||||
|
|
||||||
@@ -203,3 +222,44 @@ describe('resolveHostStarId', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the archive epoch', () => {
|
||||||
|
it("carries Barnard's star from the archive's position to where Gaia DR3's goes, to a few milliarcseconds", () => {
|
||||||
|
// The archive publishes Gaia DR2's J2015.5 position and motion; DR3's is at J2016.
|
||||||
|
const archive = propagateProperMotion(269.4486144, 4.7379808, -802.803, 10362.5, CATALOGUE_EPOCH - ARCHIVE_EPOCH);
|
||||||
|
const dr3 = propagateProperMotion(269.44850252543836, 4.739420051112412, -801.5509783684709, 10362.394206546573, CATALOGUE_EPOCH - 2016);
|
||||||
|
const [a, b] = [archive, dr3].map(({ raDeg, decDeg }) => raDegDecDistanceToXyz(raDeg, decDeg, 1));
|
||||||
|
const separationArcsec = (Math.acos(Math.min(1, a.x * b.x + a.y * b.y + a.z * b.z)) * 180 * 3600) / Math.PI;
|
||||||
|
expect(separationArcsec).toBeLessThan(0.02);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches Barnard's star from the archive's row at J2015.5, not an entry where a J2016 or J2015 row would carry it", () => {
|
||||||
|
// Half a year of its 10.4″/yr is 5.2″: a decoy placed where carrying the row back sixteen, or
|
||||||
|
// fifteen, years lands is nearer that point than Barnard's star is.
|
||||||
|
const row = { hostname: "Barnard's star", raDeg: 269.4486144, decDeg: 4.7379808, distancePc: 1.8266, pmRaMasPerYear: -802.803, pmDecMasPerYear: 10362.5 };
|
||||||
|
const at = (years: number) => propagateProperMotion(row.raDeg, row.decDeg, row.pmRaMasPerYear, row.pmDecMasPerYear, years);
|
||||||
|
const [truth, fromJ2016, fromJ2015] = [at(CATALOGUE_EPOCH - ARCHIVE_EPOCH), at(CATALOGUE_EPOCH - 2016), at(CATALOGUE_EPOCH - 2015)];
|
||||||
|
const stars = [
|
||||||
|
star(201, 'Gaia DR3 decoy', fromJ2016.raDeg, fromJ2016.decDeg, 1.8266),
|
||||||
|
star(202, 'Gaia DR3 other decoy', fromJ2015.raDeg, fromJ2015.decDeg, 1.8266),
|
||||||
|
star(200, 'GJ 699', truth.raDeg, truth.decDeg, 1.8266)
|
||||||
|
];
|
||||||
|
expect(resolveHostStarId(row, stars)).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('archiveStarId', () => {
|
||||||
|
it('gives a host the same id whatever else the archive holds, inside the range left for it', () => {
|
||||||
|
const kepler186 = archiveStarId('Kepler-186', new Set());
|
||||||
|
const others = new Set(['1RXS J160929.1-210524', 'Kepler-1860', 'Kepler-452', 'TOI-700'].map((name) => archiveStarId(name, new Set())));
|
||||||
|
expect(archiveStarId('Kepler-186', others)).toBe(kepler186);
|
||||||
|
expect(others.has(kepler186)).toBe(false);
|
||||||
|
expect(kepler186).toBeGreaterThanOrEqual(ARCHIVE_ID_BASE);
|
||||||
|
expect(kepler186).toBeLessThan(2 ** 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a host whose id is taken to the next free one', () => {
|
||||||
|
const kepler186 = archiveStarId('Kepler-186', new Set());
|
||||||
|
expect(archiveStarId('Kepler-186', new Set([kepler186, kepler186 + 1]))).toBe(kepler186 + 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,6 +15,30 @@ export interface HostStarQuery {
|
|||||||
/** μα·cos δ in mas/yr, as the archive publishes it (`sy_pmra`); missing means unknown. */
|
/** μα·cos δ in mas/yr, as the archive publishes it (`sy_pmra`); missing means unknown. */
|
||||||
pmRaMasPerYear?: number;
|
pmRaMasPerYear?: number;
|
||||||
pmDecMasPerYear?: number;
|
pmDecMasPerYear?: number;
|
||||||
|
/**
|
||||||
|
* The archive's parallax in mas (`sy_plx`), a second distance the ratio test accepts. Its
|
||||||
|
* `sy_dist` comes from TICv8 and contradicts its own parallax past the tolerance for 47 of the
|
||||||
|
* 5 959 systems that publish both — Lalande 21185 at 5.68 pc for 392 mas (2.55 pc), Luyten's
|
||||||
|
* Star at 5.92 for 263 mas, Struve 2398 B at 6.84 for 285 — and those three are in the
|
||||||
|
* catalogue, 0.1″ to 9″ from the archive's direction. A second chance rather than a
|
||||||
|
* replacement: past a few hundred parsecs the inverse of a low-S/N parallax is the worse
|
||||||
|
* estimate (K2-238, 538 pc by `sy_dist`, would be 6 779).
|
||||||
|
*/
|
||||||
|
parallaxMas?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A host's distance as the archive gives it: `sy_dist`, or where that is blank, the inverse of its
|
||||||
|
* parallax `sy_plx`; `NaN` with neither. mu2 Sco has no `sy_dist` and a 6.31 mas parallax, which
|
||||||
|
* finds Pipirima (HIP 82545) 0.4″ from the archive's direction; left blank, its planet had no star.
|
||||||
|
*/
|
||||||
|
export function archiveDistancePc(distancePc: number | undefined, parallaxMas: number | undefined): number {
|
||||||
|
return distancePc ?? (parallaxMas !== undefined && parallaxMas > 0 ? 1000 / parallaxMas : Number.NaN);
|
||||||
|
}
|
||||||
|
|
||||||
|
function distancesAgree(a: number, b: number): boolean {
|
||||||
|
const [near, far] = a < b ? [a, b] : [b, a];
|
||||||
|
return (far - near) / near <= MERGE_DISTANCE_RATIO_TOLERANCE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,15 +89,17 @@ export const HOST_TRANSVERSE_TOLERANCE_PC = 0.01;
|
|||||||
/**
|
/**
|
||||||
* The archive does not say which epoch a row's position is for, and they are demonstrably
|
* The archive does not say which epoch a row's position is for, and they are demonstrably
|
||||||
* mixed: alf Tau and GJ 273 publish J2000 (the raw position sits under an arcsecond from our
|
* mixed: alf Tau and GJ 273 publish J2000 (the raw position sits under an arcsecond from our
|
||||||
* star, and carrying it back doubles the error), HD 133131 and TOI-2459 publish Gaia's J2016
|
* star, and carrying it back doubles the error), HD 133131 and TOI-2459 publish Gaia DR2's J2015.5
|
||||||
* (the carried-back position lands to 0.1″). So every query is tried at both ends — as
|
* (the carried-back position lands to 0.1″). DR2's, not DR3's J2016, although the archive names
|
||||||
* published, and carried back sixteen years with the archive's own proper motion — and a star
|
* the DR3 source: Barnard's star, Teegarden's Star, TRAPPIST-1 and 66 of the 67 archive-placed
|
||||||
* is judged on whichever is closer. Guessing one epoch picks companions: assume J2016 and
|
* stars moving over 100 mas a year equal their DR2 position to a milliarcsecond and none their
|
||||||
* Aldebaran's planet lands on Gl 171.1B, assume J2000 and GJ 15 A's land on a Gaia entry
|
* DR3 one. So every query is tried at both ends — as published, and carried back fifteen and a
|
||||||
* 15.9″ out.
|
* half years with the archive's own proper motion — and a star is judged on whichever is closer.
|
||||||
|
* Guessing one epoch picks companions: assume the later one and Aldebaran's planet lands on Gl
|
||||||
|
* 171.1B, assume J2000 and GJ 15 A's land on a Gaia entry 15.9″ out.
|
||||||
*/
|
*/
|
||||||
const CATALOGUE_EPOCH = 2000.0;
|
export const CATALOGUE_EPOCH = 2000.0;
|
||||||
const ARCHIVE_LATEST_EPOCH = 2016.0;
|
export const ARCHIVE_EPOCH = 2015.5;
|
||||||
|
|
||||||
function knownMotion(masPerYear: number | undefined): number {
|
function knownMotion(masPerYear: number | undefined): number {
|
||||||
return Number.isFinite(masPerYear) ? (masPerYear as number) : 0;
|
return Number.isFinite(masPerYear) ? (masPerYear as number) : 0;
|
||||||
@@ -125,10 +151,11 @@ export function resolveHostStarId(
|
|||||||
// star would pass the direction test and the last one in array order would win.
|
// star would pass the direction test and the last one in array order would win.
|
||||||
knownMotion(query.pmRaMasPerYear),
|
knownMotion(query.pmRaMasPerYear),
|
||||||
knownMotion(query.pmDecMasPerYear),
|
knownMotion(query.pmDecMasPerYear),
|
||||||
CATALOGUE_EPOCH - ARCHIVE_LATEST_EPOCH
|
CATALOGUE_EPOCH - ARCHIVE_EPOCH
|
||||||
);
|
);
|
||||||
const carried = raDegDecDistanceToXyz(carriedBack.raDeg, carriedBack.decDeg, 1);
|
const carried = raDegDecDistanceToXyz(carriedBack.raDeg, carriedBack.decDeg, 1);
|
||||||
|
|
||||||
|
const parallaxPc = query.parallaxMas !== undefined && query.parallaxMas > 0 ? 1000 / query.parallaxMas : Number.NaN;
|
||||||
const minCosine = Math.cos(Math.min(Math.PI, HOST_TRANSVERSE_TOLERANCE_PC / query.distancePc));
|
const minCosine = Math.cos(Math.min(Math.PI, HOST_TRANSVERSE_TOLERANCE_PC / query.distancePc));
|
||||||
let best: StarRecord | null = null;
|
let best: StarRecord | null = null;
|
||||||
let bestCosine = -2;
|
let bestCosine = -2;
|
||||||
@@ -146,8 +173,7 @@ export function resolveHostStarId(
|
|||||||
if (cosine < minCosine || cosine <= bestCosine) {
|
if (cosine < minCosine || cosine <= bestCosine) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const [near, far] = query.distancePc < starDistance ? [query.distancePc, starDistance] : [starDistance, query.distancePc];
|
if (!distancesAgree(query.distancePc, starDistance) && !(parallaxPc > 0 && distancesAgree(parallaxPc, starDistance))) {
|
||||||
if ((far - near) / near > MERGE_DISTANCE_RATIO_TOLERANCE) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
best = star;
|
best = star;
|
||||||
@@ -156,3 +182,30 @@ export function resolveHostStarId(
|
|||||||
|
|
||||||
return best ? best.id : null;
|
return best ? best.id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the ids of the stars only the archive places begin: past Gaia's two ranges, and under
|
||||||
|
* the 2^30 `validateStars` holds every id to — which leaves 3.7 million.
|
||||||
|
*/
|
||||||
|
export const ARCHIVE_ID_BASE = 1_070_000_000;
|
||||||
|
const ARCHIVE_ID_RANGE = 2 ** 30 - ARCHIVE_ID_BASE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The id of a star the ETL places from the archive, from its host's name rather than from its
|
||||||
|
* place in the answer. Numbered in pl_name order, a refresh that added or dropped one host
|
||||||
|
* renumbered every host after it — one row dropped renamed 3 276 of 3 277 ids, and a bookmark kept
|
||||||
|
* on Kepler-186 opened Kepler-1860 — while HYG's and Gaia's ids hold. FNV-1a over the name, into
|
||||||
|
* the range above; a name whose id is taken takes the next free one, the one case a refresh can
|
||||||
|
* still move, and only between the two names that collided.
|
||||||
|
*/
|
||||||
|
export function archiveStarId(hostname: string, taken: ReadonlySet<number>): number {
|
||||||
|
let hash = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < hostname.length; i++) {
|
||||||
|
hash = Math.imul(hash ^ hostname.charCodeAt(i), 0x01000193) >>> 0;
|
||||||
|
}
|
||||||
|
let offset = hash % ARCHIVE_ID_RANGE;
|
||||||
|
while (taken.has(ARCHIVE_ID_BASE + offset)) {
|
||||||
|
offset = (offset + 1) % ARCHIVE_ID_RANGE;
|
||||||
|
}
|
||||||
|
return ARCHIVE_ID_BASE + offset;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { GM_SUN_AU3_PER_DAY2, DEFAULT_EPOCH_JD } from './constants';
|
|||||||
import {
|
import {
|
||||||
gravitationalParameterFromPeriod,
|
gravitationalParameterFromPeriod,
|
||||||
isPropagatableOrbit,
|
isPropagatableOrbit,
|
||||||
|
meanElementsAt,
|
||||||
meanMotionRadPerDay,
|
meanMotionRadPerDay,
|
||||||
orbitEllipsePoints,
|
orbitEllipsePoints,
|
||||||
orbitalPeriodDays,
|
orbitalPeriodDays,
|
||||||
|
positionAtEpoch,
|
||||||
positionAtTrueAnomaly,
|
positionAtTrueAnomaly,
|
||||||
propagateOrbit,
|
propagateOrbit,
|
||||||
resolveGravitationalParameter,
|
resolveGravitationalParameter,
|
||||||
@@ -131,6 +133,46 @@ describe('propagateOrbit', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('meanElementsAt', () => {
|
||||||
|
/** A circle in the reference plane, prograde (0) or retrograde (180), whose node turns. */
|
||||||
|
function circle(inclinationDeg: number) {
|
||||||
|
return { semiMajorAxisAu: 1, eccentricity: 0, inclinationDeg, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD };
|
||||||
|
}
|
||||||
|
const RATES = { meanMotionDegPerDay: 10, longitudeOfAscendingNodeDegPerDay: 0.5, argumentOfPeriapsisDegPerDay: 0.2 };
|
||||||
|
|
||||||
|
/** Longitude in the reference plane a day on, in degrees, signed. */
|
||||||
|
function longitudeAfterOneDay(inclinationDeg: number): number {
|
||||||
|
const { x, y } = positionAtEpoch(meanElementsAt(circle(inclinationDeg), RATES, DEFAULT_EPOCH_JD + 1));
|
||||||
|
return (Math.atan2(y, x) * 180) / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('goes round at its mean motion however its node and periapsis turn', () => {
|
||||||
|
expect(longitudeAfterOneDay(0)).toBeCloseTo(10, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goes round a retrograde orbit backwards at the same rate, the node’s turning added back', () => {
|
||||||
|
// Taking the node off as for a prograde orbit made this 9 degrees, and Triton drifted a
|
||||||
|
// degree a year from where Horizons has it.
|
||||||
|
expect(longitudeAfterOneDay(180)).toBeCloseTo(-10, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns the node and periapsis at their own rates, and dates the result', () => {
|
||||||
|
const later = meanElementsAt(circle(0), RATES, DEFAULT_EPOCH_JD + 4);
|
||||||
|
expect(later.longitudeOfAscendingNodeDeg).toBeCloseTo(2, 12);
|
||||||
|
expect(later.argumentOfPeriapsisDeg).toBeCloseTo(0.8, 12);
|
||||||
|
expect(later.epochJd).toBe(DEFAULT_EPOCH_JD + 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds Standish’s b T² + c cos(fT) + s sin(fT) to the mean anomaly', () => {
|
||||||
|
const terms = { b: -0.00012452, c: 0.0606406, s: -0.35635438, f: 38.35125 };
|
||||||
|
const T = 0.7;
|
||||||
|
const withTerms = meanElementsAt(circle(0), { ...RATES, meanAnomalyTerms: terms }, DEFAULT_EPOCH_JD + T * 36525);
|
||||||
|
const without = meanElementsAt(circle(0), RATES, DEFAULT_EPOCH_JD + T * 36525);
|
||||||
|
const f = (terms.f * T * Math.PI) / 180;
|
||||||
|
expect(withTerms.meanAnomalyAtEpochDeg - without.meanAnomalyAtEpochDeg).toBeCloseTo(terms.b * T * T + terms.c * Math.cos(f) + terms.s * Math.sin(f), 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('orbitEllipsePoints', () => {
|
describe('orbitEllipsePoints', () => {
|
||||||
it('samples a closed loop whose distances stay within the periapsis/apoapsis bounds', () => {
|
it('samples a closed loop whose distances stay within the periapsis/apoapsis bounds', () => {
|
||||||
const elements = resolveOrbitalElements({ semiMajorAxisAu: 5, eccentricity: 0.4 });
|
const elements = resolveOrbitalElements({ semiMajorAxisAu: 5, eccentricity: 0.4 });
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { CartesianCoordinates } from './coordinates';
|
import { CartesianCoordinates } from './coordinates';
|
||||||
import { DEFAULT_EPOCH_JD, GM_SUN_AU3_PER_DAY2 } from './constants';
|
import { DEFAULT_EPOCH_JD, GM_SUN_AU3_PER_DAY2 } from './constants';
|
||||||
import { OrbitalElements } from '../models/body.model';
|
import { MeanElementRates, OrbitalElements } from '../models/body.model';
|
||||||
|
|
||||||
const DEG_TO_RAD = Math.PI / 180;
|
const DEG_TO_RAD = Math.PI / 180;
|
||||||
const TWO_PI = Math.PI * 2;
|
const TWO_PI = Math.PI * 2;
|
||||||
|
const DAYS_PER_JULIAN_CENTURY = 36525;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fills in the elements the Kepler propagator needs but that some sources (e.g. exoplanets,
|
* Fills in the elements the Kepler propagator needs but that some sources (e.g. exoplanets,
|
||||||
@@ -208,17 +209,63 @@ export function positionAtTrueAnomaly(elements: OrbitalElements, trueAnomalyRad:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Propagates `elements` to Julian date `epochJdEval`, returning the body's position (AU)
|
* The rates of an orbit that only goes round: Kepler's mean motion from the central mass, with
|
||||||
* relative to its central body. This is the app's "current epoch" evaluation used for live
|
* nothing turning. What an exoplanet has, since the archive publishes no precession.
|
||||||
* (and future time-scrubbable) positions, as opposed to {@link orbitEllipsePoints} which
|
*/
|
||||||
|
export function keplerRates(semiMajorAxisAu: number, gmAu3PerDay2: number): MeanElementRates {
|
||||||
|
return {
|
||||||
|
meanMotionDegPerDay: meanMotionRadPerDay(semiMajorAxisAu, gmAu3PerDay2) / DEG_TO_RAD,
|
||||||
|
longitudeOfAscendingNodeDegPerDay: 0,
|
||||||
|
argumentOfPeriapsisDegPerDay: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The elements at `epochJdEval`, each moved from its epoch at its own rate, and returned with that
|
||||||
|
* date as their epoch — so {@link positionAtEpoch} places the body, and the node and periapsis
|
||||||
|
* say where to draw the orbit it is on.
|
||||||
|
*
|
||||||
|
* The mean anomaly is what is left of the body's motion once the node and periapsis have turned:
|
||||||
|
* `meanMotionDegPerDay` is how fast it goes round in space, and a periapsis that has moved on is
|
||||||
|
* that much further to reach. On a retrograde orbit, past 90 degrees, the body runs against the
|
||||||
|
* direction the node is counted in, so the node's turning is added back rather than taken off.
|
||||||
|
* Taken off, Triton — whose node turns half a degree a year — drifted a degree a year from where
|
||||||
|
* Horizons has it, 105 degrees by 2100.
|
||||||
|
*/
|
||||||
|
export function meanElementsAt(elements: OrbitalElements, rates: MeanElementRates, epochJdEval: number): OrbitalElements {
|
||||||
|
const days = epochJdEval - elements.epochJd;
|
||||||
|
const node = rates.longitudeOfAscendingNodeDegPerDay * days;
|
||||||
|
const periapsis = rates.argumentOfPeriapsisDegPerDay * days;
|
||||||
|
const nodeAlongOrbit = elements.inclinationDeg > 90 ? -node : node;
|
||||||
|
const terms = rates.meanAnomalyTerms;
|
||||||
|
const centuries = days / DAYS_PER_JULIAN_CENTURY;
|
||||||
|
const extra = terms
|
||||||
|
? terms.b * centuries * centuries + terms.c * Math.cos(terms.f * centuries * DEG_TO_RAD) + terms.s * Math.sin(terms.f * centuries * DEG_TO_RAD)
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
semiMajorAxisAu: elements.semiMajorAxisAu + (rates.semiMajorAxisAuPerDay ?? 0) * days,
|
||||||
|
eccentricity: elements.eccentricity + (rates.eccentricityPerDay ?? 0) * days,
|
||||||
|
inclinationDeg: elements.inclinationDeg + (rates.inclinationDegPerDay ?? 0) * days,
|
||||||
|
longitudeOfAscendingNodeDeg: elements.longitudeOfAscendingNodeDeg + node,
|
||||||
|
argumentOfPeriapsisDeg: elements.argumentOfPeriapsisDeg + periapsis,
|
||||||
|
meanAnomalyAtEpochDeg: elements.meanAnomalyAtEpochDeg + rates.meanMotionDegPerDay * days - periapsis - nodeAlongOrbit + extra,
|
||||||
|
epochJd: epochJdEval
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where `elements` put the body at their own epoch (AU, relative to the central body). */
|
||||||
|
export function positionAtEpoch(elements: OrbitalElements): CartesianCoordinates {
|
||||||
|
const eccentricAnomalyRad = solveEccentricAnomaly(elements.meanAnomalyAtEpochDeg * DEG_TO_RAD, elements.eccentricity);
|
||||||
|
return positionAtTrueAnomaly(elements, trueAnomalyFromEccentricAnomaly(eccentricAnomalyRad, elements.eccentricity));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propagates `elements` to Julian date `epochJdEval` around a central mass, returning the body's
|
||||||
|
* position (AU) relative to its central body, as opposed to {@link orbitEllipsePoints} which
|
||||||
* samples the fixed orbit shape independent of time.
|
* samples the fixed orbit shape independent of time.
|
||||||
*/
|
*/
|
||||||
export function propagateOrbit(elements: OrbitalElements, gmAu3PerDay2: number, epochJdEval: number): CartesianCoordinates {
|
export function propagateOrbit(elements: OrbitalElements, gmAu3PerDay2: number, epochJdEval: number): CartesianCoordinates {
|
||||||
const meanMotion = meanMotionRadPerDay(elements.semiMajorAxisAu, gmAu3PerDay2);
|
return positionAtEpoch(meanElementsAt(elements, keplerRates(elements.semiMajorAxisAu, gmAu3PerDay2), epochJdEval));
|
||||||
const meanAnomalyRad = elements.meanAnomalyAtEpochDeg * DEG_TO_RAD + meanMotion * (epochJdEval - elements.epochJd);
|
|
||||||
const eccentricAnomalyRad = solveEccentricAnomaly(meanAnomalyRad, elements.eccentricity);
|
|
||||||
const trueAnomalyRad = trueAnomalyFromEccentricAnomaly(eccentricAnomalyRad, elements.eccentricity);
|
|
||||||
return positionAtTrueAnomaly(elements, trueAnomalyRad);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { parsePlanetMeanElements, parseSatelliteMeanElements, parseSmallBodyElements, SbdbAnswer } from './mean-elements';
|
||||||
|
|
||||||
|
/** Standish's p_elem_t2.txt, cut to the lines that matter here, as JPL published them. */
|
||||||
|
const TABLE_2 = `Keplerian elements and their rates, with respect to the mean ecliptic and equinox of J2000,
|
||||||
|
valid for the time-interval 3000 BC -- 3000 AD. NOTE: the computation of M for Jupiter through
|
||||||
|
Pluto *must* be augmented by the additional terms given in Table 2b (below).
|
||||||
|
|
||||||
|
EM Bary 1.00000018 0.01673163 -0.00054346 100.46691572 102.93005885 -5.11260389
|
||||||
|
-0.00000003 -0.00003661 -0.01337178 35999.37306329 0.31795260 -0.24123856
|
||||||
|
Jupiter 5.20248019 0.04853590 1.29861416 34.33479152 14.27495244 100.29282654
|
||||||
|
-0.00002864 0.00018026 -0.00322699 3034.90371757 0.18199196 0.13024619
|
||||||
|
Pluto 39.48686035 0.24885238 17.14104260 238.96535011 224.09702598 110.30167986
|
||||||
|
0.00449751 0.00006016 0.00000501 145.18042903 -0.00968827 -0.00809981
|
||||||
|
|
||||||
|
Table 2b.
|
||||||
|
Jupiter -0.00012452 0.06064060 -0.35635438 38.35125000
|
||||||
|
Pluto -0.01262724
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** The satellite page's markup around three rows, as the 2021 page served it. */
|
||||||
|
const SATELLITES = `
|
||||||
|
<td align="left" nowrap><b>Satellites of Earth</b></td>
|
||||||
|
<td align="right" nowrap><b>jump to:</b> <a href="#earth">Earth</a>, <a href="#mars">Mars</a></td>
|
||||||
|
<H3>Mean <a href="?glossary&term=ecliptic">ecliptic</a> orbital elements</H3>
|
||||||
|
Epoch 2000 Jan. 1.50 TT<BR>
|
||||||
|
<TR ALIGN=right><TD ALIGN=left>Moon</TD>
|
||||||
|
<TD>384400.</TD><TD>0.0554</TD><TD>318.15</TD><TD>135.27</TD><TD>5.16</TD><TD>125.08</TD>
|
||||||
|
<TD>13.176358</TD><TD>27.322</TD><TD>5.997</TD><TD>18.600</TD>
|
||||||
|
<TD ALIGN=right><A HREF="#ref1">1</A></TD></TR>
|
||||||
|
<td align="left" nowrap><b>Satellites of Jupiter</b></td>
|
||||||
|
<td align="right" nowrap><b>jump to:</b> <a href="#earth">Earth</a>, <a href="#mars">Mars</a></td>
|
||||||
|
<H3>Mean orbital elements referred to the local <a href="?glossary&term=lp">Laplace planes</a></H3>
|
||||||
|
Epoch 1997 Jan. 16.00 TT<BR>
|
||||||
|
<TR ALIGN=right><TD ALIGN=left>Io</TD><TD>421800.</TD><TD>0.0041</TD>
|
||||||
|
<TD>84.129</TD><TD>342.021</TD><TD>0.036</TD><TD>43.977</TD><TD>203.4889583</TD>
|
||||||
|
<TD>1.769</TD><TD>1.625</TD><TD>7.420</TD><TD>268.057</TD><TD>64.495</TD>
|
||||||
|
<TD>0.000</TD>
|
||||||
|
<TD ALIGN=right><A HREF="#ref11">11</A></TD></TR>
|
||||||
|
<td align="left" nowrap><b>Satellites of Neptune</b></td>
|
||||||
|
<td align="right" nowrap><b>jump to:</b> <a href="#earth">Earth</a>, <a href="#mars">Mars</a></td>
|
||||||
|
<H3>Mean orbital elements referred to the local <a href="?glossary&term=lp">Laplace planes</a></H3>
|
||||||
|
Epoch 2000 Jan. 1.50 TT<BR>
|
||||||
|
<TR ALIGN=right><TD ALIGN=left>Triton</TD><TD>354759.</TD><TD>0.0000</TD>
|
||||||
|
<TD>66.142</TD><TD>352.257</TD><TD>156.865</TD><TD>177.608</TD>
|
||||||
|
<TD>61.2572638</TD><TD>5.877</TD><TD>386.371</TD><TD>687.446</TD>
|
||||||
|
<TD>299.456</TD><TD>43.414</TD><TD>0.010</TD>
|
||||||
|
<TD ALIGN=right><A HREF="#ref54">54</A></TD></TR>
|
||||||
|
<td align="left" nowrap><b>Satellites of Uranus</b></td>
|
||||||
|
<td align="right" nowrap><b>jump to:</b> <a href="#earth">Earth</a>, <a href="#mars">Mars</a></td>
|
||||||
|
<H3>Mean equatorial orbital elements</H3>
|
||||||
|
Epoch 1980 Jan. 1.0 TT<BR>
|
||||||
|
<TR ALIGN=right><TD ALIGN=left>Titania</TD><TD>436300.</TD><TD>0.0011</TD>
|
||||||
|
<TD>284.400</TD><TD>24.614</TD><TD>0.079</TD><TD>99.771</TD><TD>41.3514246</TD>
|
||||||
|
<TD>8.706</TD><TD>161.525</TD><TD>195.369</TD>
|
||||||
|
<TD ALIGN=right><A HREF="#ref10">10</A></TD></TR>
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Ceres as the SBDB API answers `sstr=Ceres&phys-par=1&full-prec=1`, cut to what is read. */
|
||||||
|
const CERES: SbdbAnswer = {
|
||||||
|
orbit: {
|
||||||
|
epoch: '2461200.5',
|
||||||
|
elements: [
|
||||||
|
{ name: 'e', value: '.07969229514816586' },
|
||||||
|
{ name: 'a', value: '2.765552595034094' },
|
||||||
|
{ name: 'q', value: '2.545159361382861' },
|
||||||
|
{ name: 'i', value: '10.58802780183462' },
|
||||||
|
{ name: 'om', value: '80.24862682043221' },
|
||||||
|
{ name: 'w', value: '73.29421453021587' },
|
||||||
|
{ name: 'ma', value: '274.4193463761342' },
|
||||||
|
{ name: 'tp', value: '2461599.841466614066' },
|
||||||
|
{ name: 'per', value: '1679.853119758983' },
|
||||||
|
{ name: 'n', value: '.21430445064843' },
|
||||||
|
{ name: 'ad', value: '2.985945828685327' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
phys_par: [
|
||||||
|
{ name: 'H', value: '3.34' },
|
||||||
|
{ name: 'diameter', value: '939.4' },
|
||||||
|
{ name: 'GM', value: '62.6284' },
|
||||||
|
{ name: 'rot_per', value: '9.074170' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('parsePlanetMeanElements', () => {
|
||||||
|
it('turns Standish’s longitudes into the argument of periapsis and mean anomaly', () => {
|
||||||
|
const { orbit } = parsePlanetMeanElements(TABLE_2, 'jupiter');
|
||||||
|
expect(orbit.argumentOfPeriapsisDeg).toBeCloseTo(14.27495244 - 100.29282654, 8);
|
||||||
|
expect(orbit.meanAnomalyAtEpochDeg).toBeCloseTo(34.33479152 - 14.27495244, 8);
|
||||||
|
expect(orbit.epochJd).toBe(2451545);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the rates per day, the mean motion being the mean longitude’s', () => {
|
||||||
|
const { rates } = parsePlanetMeanElements(TABLE_2, 'earth');
|
||||||
|
// 35 999.373 degrees a century is the sidereal year.
|
||||||
|
expect(360 / rates.meanMotionDegPerDay).toBeCloseTo(365.2564, 4);
|
||||||
|
expect(rates.argumentOfPeriapsisDegPerDay * 36525).toBeCloseTo(0.3179526 + 0.24123856, 8);
|
||||||
|
// And every other rate the row gives, a century's worth: dropped, Saturn moved 0.66 degrees by
|
||||||
|
// AD 1 without its node's and 0.36 by AD 3000 without its a, e and i, where no date from 1950 to
|
||||||
|
// 2100 shows more than 0.036.
|
||||||
|
expect(rates.longitudeOfAscendingNodeDegPerDay * 36525).toBeCloseTo(-0.24123856, 8);
|
||||||
|
expect(rates.semiMajorAxisAuPerDay! * 36525).toBeCloseTo(-0.00000003, 8);
|
||||||
|
expect(rates.eccentricityPerDay! * 36525).toBeCloseTo(-0.00003661, 8);
|
||||||
|
expect(rates.inclinationDegPerDay! * 36525).toBeCloseTo(-0.01337178, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries Table 2b’s terms for Jupiter and beyond, and none for the inner planets', () => {
|
||||||
|
expect(parsePlanetMeanElements(TABLE_2, 'jupiter').rates.meanAnomalyTerms).toEqual({ b: -0.00012452, c: 0.0606406, s: -0.35635438, f: 38.35125 });
|
||||||
|
expect(parsePlanetMeanElements(TABLE_2, 'earth').rates.meanAnomalyTerms).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads Pluto’s row, not the note above the table that starts a line with its name', () => {
|
||||||
|
const pluto = parsePlanetMeanElements(TABLE_2, 'pluto');
|
||||||
|
expect(pluto.orbit.semiMajorAxisAu).toBe(39.48686035);
|
||||||
|
expect(pluto.rates.meanAnomalyTerms).toEqual({ b: -0.01262724, c: 0, s: 0, f: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseSatelliteMeanElements', () => {
|
||||||
|
it('reads a Laplace-plane row with its pole and its section’s epoch', () => {
|
||||||
|
const io = parseSatelliteMeanElements(SATELLITES, 'Jupiter', 'Io', true);
|
||||||
|
expect(io.laplacePole).toEqual({ raDeg: 268.057, decDeg: 64.495 });
|
||||||
|
expect(io.orbit.epochJd).toBe(2450464.5);
|
||||||
|
expect(io.rates.meanMotionDegPerDay).toBe(203.4889583);
|
||||||
|
expect(io.orbitSource).toBe('JPL SSD satellite mean elements, epoch 1997 Jan 16');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the Moon against the ecliptic, with no pole', () => {
|
||||||
|
const moon = parseSatelliteMeanElements(SATELLITES, 'Earth', 'Moon', false);
|
||||||
|
expect(moon.laplacePole).toBeUndefined();
|
||||||
|
expect(moon.orbit.epochJd).toBe(2451545);
|
||||||
|
expect(moon.orbit.semiMajorAxisAu * 149597870.7).toBeCloseTo(384400, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regresses a prograde node and advances a periapsis, as the planet’s oblateness turns them', () => {
|
||||||
|
const { rates } = parseSatelliteMeanElements(SATELLITES, 'Earth', 'Moon', false);
|
||||||
|
expect(rates.longitudeOfAscendingNodeDegPerDay).toBeCloseTo(-360 / (18.6 * 365.25), 9);
|
||||||
|
expect(rates.argumentOfPeriapsisDegPerDay).toBeCloseTo(360 / (5.997 * 365.25), 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advances the node of a retrograde orbit', () => {
|
||||||
|
const { rates } = parseSatelliteMeanElements(SATELLITES, 'Neptune', 'Triton', false);
|
||||||
|
expect(rates.longitudeOfAscendingNodeDegPerDay).toBeCloseTo(360 / (687.446 * 365.25), 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a section referred to the planet’s equator against the pole it is given', () => {
|
||||||
|
const pole = { raDeg: 77.311, decDeg: 15.175 };
|
||||||
|
const titania = parseSatelliteMeanElements(SATELLITES, 'Uranus', 'Titania', false, pole);
|
||||||
|
expect(titania.laplacePole).toEqual(pole);
|
||||||
|
expect(titania.orbit.epochJd).toBe(2444239.5);
|
||||||
|
expect(titania.rates.meanMotionDegPerDay).toBe(41.3514246);
|
||||||
|
// Read as ecliptic elements, which is what a missing pole would mean, Titania is 88 degrees
|
||||||
|
// from Horizons on 2025-01-01.
|
||||||
|
expect(() => parseSatelliteMeanElements(SATELLITES, 'Uranus', 'Titania', false)).toThrow(/equator/);
|
||||||
|
expect(() => parseSatelliteMeanElements(SATELLITES, 'Jupiter', 'Io', true, pole)).toThrow(/equator/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns the periapsis backwards where a resonance holds it', () => {
|
||||||
|
const { rates } = parseSatelliteMeanElements(SATELLITES, 'Jupiter', 'Io', true);
|
||||||
|
expect(rates.argumentOfPeriapsisDegPerDay).toBeCloseTo(-360 / (1.625 * 365.25), 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseSmallBodyElements', () => {
|
||||||
|
it('carries a dwarf planet on its osculating elements at their own mean motion', () => {
|
||||||
|
const ceres = parseSmallBodyElements(CERES);
|
||||||
|
expect(ceres.orbit).toEqual({
|
||||||
|
semiMajorAxisAu: 2.765552595034094,
|
||||||
|
eccentricity: 0.07969229514816586,
|
||||||
|
inclinationDeg: 10.58802780183462,
|
||||||
|
longitudeOfAscendingNodeDeg: 80.24862682043221,
|
||||||
|
argumentOfPeriapsisDeg: 73.29421453021587,
|
||||||
|
meanAnomalyAtEpochDeg: 274.4193463761342,
|
||||||
|
epochJd: 2461200.5
|
||||||
|
});
|
||||||
|
expect(ceres.rates).toEqual({ meanMotionDegPerDay: 0.21430445064843, longitudeOfAscendingNodeDegPerDay: 0, argumentOfPeriapsisDegPerDay: 0 });
|
||||||
|
expect(ceres.laplacePole).toBeUndefined();
|
||||||
|
expect(ceres.orbitSource).toBe('JPL SBDB osculating elements, epoch 2026 Jun 9');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes half the diameter as the radius, and the rotation period in hours', () => {
|
||||||
|
const ceres = parseSmallBodyElements(CERES);
|
||||||
|
expect(ceres.radiusKm).toBe(469.7);
|
||||||
|
expect(ceres.rotationPeriodHours).toBe(9.07417);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves out what the answer does not publish', () => {
|
||||||
|
const eris = parseSmallBodyElements({ ...CERES, phys_par: [{ name: 'rot_per', value: '25.9' }] });
|
||||||
|
expect(eris.radiusKm).toBeUndefined();
|
||||||
|
expect(eris.rotationPeriodHours).toBe(25.9);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { MeanElementRates, OrbitalElements } from '../models/body.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads JPL's two tables of mean orbital elements, which the ETL fetches (see
|
||||||
|
* `tools/etl/lib/mean-elements.ts`), into the elements and rates `bodies.json` carries.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KM_PER_AU = 149597870.7;
|
||||||
|
const J2000_JD = 2451545.0;
|
||||||
|
const DAYS_PER_JULIAN_CENTURY = 36525;
|
||||||
|
const DAYS_PER_JULIAN_YEAR = 365.25;
|
||||||
|
|
||||||
|
const PLANET_ORBIT_SOURCE = 'JPL approximate mean elements (Standish), fit for 3000 BC to AD 3000';
|
||||||
|
|
||||||
|
export interface MeanOrbit {
|
||||||
|
orbit: OrbitalElements;
|
||||||
|
rates: MeanElementRates;
|
||||||
|
laplacePole?: { raDeg: number; decDeg: number };
|
||||||
|
orbitSource: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Table 2a's name for each planet; Earth's row is the Earth-Moon barycentre, 4 700 km off Earth. */
|
||||||
|
const PLANET_ROW_NAMES: Record<string, string> = {
|
||||||
|
mercury: 'Mercury',
|
||||||
|
venus: 'Venus',
|
||||||
|
earth: 'EM Bary',
|
||||||
|
mars: 'Mars',
|
||||||
|
jupiter: 'Jupiter',
|
||||||
|
saturn: 'Saturn',
|
||||||
|
uranus: 'Uranus',
|
||||||
|
neptune: 'Neptune',
|
||||||
|
pluto: 'Pluto'
|
||||||
|
};
|
||||||
|
|
||||||
|
function numbers(text: string): number[] {
|
||||||
|
return text.trim().split(/\s+/).map(Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads one planet's row pair from Table 2a, and its Table 2b terms where it has them. The
|
||||||
|
* elements are Standish's own — a, e, I, mean longitude L, longitude of perihelion ϖ, node Ω —
|
||||||
|
* turned into the argument of periapsis ϖ - Ω and mean anomaly L - ϖ the propagator takes.
|
||||||
|
*/
|
||||||
|
export function parsePlanetMeanElements(text: string, bodyId: string): MeanOrbit {
|
||||||
|
const name = PLANET_ROW_NAMES[bodyId];
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
// A row is the name followed by a number: the notes above the table start a line with "Pluto" too.
|
||||||
|
const rows = lines.flatMap((line, index) => (name && new RegExp(`^${name}\\s+-?[\\d.]`).test(line) ? [index] : []));
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new Error(`No row for ${bodyId} in Standish's Table 2a.`);
|
||||||
|
}
|
||||||
|
const values = [...numbers(lines[rows[0]].slice(name.length)), ...numbers(lines[rows[0] + 1])];
|
||||||
|
if (values.length !== 12 || !values.every(Number.isFinite)) {
|
||||||
|
throw new Error(`Standish's Table 2a rows for ${name} did not parse: ${values.join(' ')}`);
|
||||||
|
}
|
||||||
|
const [a, e, inclination, meanLongitude, perihelion, node, aRate, eRate, inclinationRate, meanLongitudeRate, perihelionRate, nodeRate] = values;
|
||||||
|
// Table 2b repeats the name further down, with b, c, s, f (Pluto has b alone).
|
||||||
|
const extra = rows[1] === undefined ? undefined : numbers(lines[rows[1]].slice(name.length));
|
||||||
|
const [b = 0, c = 0, s = 0, f = 0] = extra ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
orbit: {
|
||||||
|
semiMajorAxisAu: a,
|
||||||
|
eccentricity: e,
|
||||||
|
inclinationDeg: inclination,
|
||||||
|
longitudeOfAscendingNodeDeg: node,
|
||||||
|
argumentOfPeriapsisDeg: perihelion - node,
|
||||||
|
meanAnomalyAtEpochDeg: meanLongitude - perihelion,
|
||||||
|
epochJd: J2000_JD
|
||||||
|
},
|
||||||
|
rates: {
|
||||||
|
meanMotionDegPerDay: meanLongitudeRate / DAYS_PER_JULIAN_CENTURY,
|
||||||
|
longitudeOfAscendingNodeDegPerDay: nodeRate / DAYS_PER_JULIAN_CENTURY,
|
||||||
|
argumentOfPeriapsisDegPerDay: (perihelionRate - nodeRate) / DAYS_PER_JULIAN_CENTURY,
|
||||||
|
semiMajorAxisAuPerDay: aRate / DAYS_PER_JULIAN_CENTURY,
|
||||||
|
eccentricityPerDay: eRate / DAYS_PER_JULIAN_CENTURY,
|
||||||
|
inclinationDegPerDay: inclinationRate / DAYS_PER_JULIAN_CENTURY,
|
||||||
|
...(extra ? { meanAnomalyTerms: { b, c, s, f } } : {})
|
||||||
|
},
|
||||||
|
orbitSource: PLANET_ORBIT_SOURCE
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
|
/** `1997 Jan. 16.00` as a Julian date. TT and TDB differ by under two milliseconds. */
|
||||||
|
function julianDate(year: number, month: string, day: number): number {
|
||||||
|
const monthIndex = MONTHS.indexOf(month);
|
||||||
|
if (monthIndex < 0) {
|
||||||
|
throw new Error(`Unknown month ${month}.`);
|
||||||
|
}
|
||||||
|
return Date.UTC(year, monthIndex, 1) / 86400000 + 2440587.5 + day - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads one moon's row from the satellite page: `a e w M i node n P Pw Pnode`, then the Laplace
|
||||||
|
* pole `RA Dec Tilt` where the section is referred to one, then a reference number.
|
||||||
|
*
|
||||||
|
* The page gives the two precession periods as magnitudes, so their sense is supplied here. A
|
||||||
|
* node driven by the planet's oblateness regresses on a prograde orbit and advances on a
|
||||||
|
* retrograde one, and the orbit's inclination says which. A periapsis advances — except where a
|
||||||
|
* resonance forces the eccentricity, which `apsidesRegress` names: Io's and Europa's are held to
|
||||||
|
* the line of their conjunctions, which turns backwards at 2 n(Europa) - n(Io) = 0.74 degrees a
|
||||||
|
* day, and that is exactly the 1.625- and 1.394-year periods the table gives for them. Read as
|
||||||
|
* advancing, Io was 0.9 degrees out and Europa 2.1.
|
||||||
|
*
|
||||||
|
* Uranus's and Pluto's sections are referred to the planet's equator instead, and the page does
|
||||||
|
* not print its pole, so the caller passes it as `equatorPole`: the elements are then read
|
||||||
|
* against that pole exactly as against a Laplace plane's.
|
||||||
|
*/
|
||||||
|
export function parseSatelliteMeanElements(
|
||||||
|
html: string,
|
||||||
|
planetName: string,
|
||||||
|
moonName: string,
|
||||||
|
apsidesRegress: boolean,
|
||||||
|
equatorPole?: { raDeg: number; decDeg: number }
|
||||||
|
): MeanOrbit {
|
||||||
|
const text = html.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ');
|
||||||
|
const section = text.indexOf(`Satellites of ${planetName} jump to`);
|
||||||
|
if (section < 0) {
|
||||||
|
throw new Error(`No section for the satellites of ${planetName}.`);
|
||||||
|
}
|
||||||
|
const row = text.slice(section).match(new RegExp(` ${moonName} ((?:-?[\\d.]+ )+)`));
|
||||||
|
if (!row || row.index === undefined) {
|
||||||
|
throw new Error(`No row for ${moonName} among the satellites of ${planetName}.`);
|
||||||
|
}
|
||||||
|
const before = text.slice(section, section + row.index);
|
||||||
|
const epoch = [...before.matchAll(/Epoch (\d{4}) (\w{3})\. ([\d.]+) T/g)].at(-1);
|
||||||
|
if (!epoch) {
|
||||||
|
throw new Error(`No epoch above ${moonName}'s row.`);
|
||||||
|
}
|
||||||
|
// The nearest heading above the row says which plane its section is referred to; the ecliptic
|
||||||
|
// where there is none.
|
||||||
|
const [plane] = ['Mean ecliptic', 'Laplace plane', 'Mean equatorial'].sort((x, y) => before.lastIndexOf(y) - before.lastIndexOf(x));
|
||||||
|
const laplace = plane === 'Laplace plane';
|
||||||
|
const equatorial = plane === 'Mean equatorial';
|
||||||
|
if (equatorial !== (equatorPole !== undefined)) {
|
||||||
|
throw new Error(`${moonName}'s elements are ${equatorial ? '' : 'not '}referred to ${planetName}'s equator, and its pole was ${equatorPole ? '' : 'not '}given.`);
|
||||||
|
}
|
||||||
|
const values = numbers(row[1]);
|
||||||
|
const expected = laplace ? 14 : 11;
|
||||||
|
if (values.length !== expected || !values.every(Number.isFinite)) {
|
||||||
|
throw new Error(`${moonName}'s row has ${values.length} numbers, ${expected} expected: ${row[1]}`);
|
||||||
|
}
|
||||||
|
const [aKm, e, periapsis, meanAnomaly, inclination, node, meanMotion, , periapsisPeriodYears, nodePeriodYears, raDeg, decDeg] = values;
|
||||||
|
const nodeSense = inclination > 90 ? 1 : -1;
|
||||||
|
const periapsisSense = apsidesRegress ? -1 : 1;
|
||||||
|
const perDay = (periodYears: number): number => (periodYears > 0 ? 360 / (periodYears * DAYS_PER_JULIAN_YEAR) : 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
orbit: {
|
||||||
|
semiMajorAxisAu: aKm / KM_PER_AU,
|
||||||
|
eccentricity: e,
|
||||||
|
inclinationDeg: inclination,
|
||||||
|
longitudeOfAscendingNodeDeg: node,
|
||||||
|
argumentOfPeriapsisDeg: periapsis,
|
||||||
|
meanAnomalyAtEpochDeg: meanAnomaly,
|
||||||
|
epochJd: julianDate(Number(epoch[1]), epoch[2], Number(epoch[3]))
|
||||||
|
},
|
||||||
|
rates: {
|
||||||
|
meanMotionDegPerDay: meanMotion,
|
||||||
|
longitudeOfAscendingNodeDegPerDay: nodeSense * perDay(nodePeriodYears),
|
||||||
|
argumentOfPeriapsisDegPerDay: periapsisSense * perDay(periapsisPeriodYears)
|
||||||
|
},
|
||||||
|
...(laplace ? { laplacePole: { raDeg, decDeg } } : equatorPole ? { laplacePole: equatorPole } : {}),
|
||||||
|
orbitSource: `JPL SSD satellite mean elements, epoch ${epoch[1]} ${epoch[2]} ${Math.floor(Number(epoch[3]))}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What this reads of a JPL Small-Body Database answer (`sbdb.api?sstr=…&phys-par=1&full-prec=1`). */
|
||||||
|
export interface SbdbAnswer {
|
||||||
|
orbit: { epoch: string; elements: Array<{ name: string; value: string | null }> };
|
||||||
|
phys_par?: Array<{ name: string; value: string | null }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmallBody extends MeanOrbit {
|
||||||
|
radiusKm?: number;
|
||||||
|
rotationPeriodHours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dwarf planet from the Small-Body Database: its osculating heliocentric elements against the
|
||||||
|
* J2000 ecliptic, the frame Standish's are in, carried round at their own mean motion n with
|
||||||
|
* nothing turning. Standish's tables stop at Pluto and JPL publishes no mean elements for the
|
||||||
|
* others, so these are exact on their epoch and drift from it — for Ceres, whose orbit Jupiter
|
||||||
|
* pulls on, by degrees within decades; see the ETL's check against Horizons.
|
||||||
|
*
|
||||||
|
* Radius and spin come from the same answer where it has them: half the published diameter, and
|
||||||
|
* the rotation period, in hours.
|
||||||
|
*/
|
||||||
|
export function parseSmallBodyElements(answer: SbdbAnswer): SmallBody {
|
||||||
|
const element = (name: string): number => {
|
||||||
|
const value = Number(answer.orbit.elements.find((candidate) => candidate.name === name)?.value ?? NaN);
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
throw new Error(`The SBDB answer has no element ${name}.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const physical = (name: string): number | undefined => {
|
||||||
|
const value = Number(answer.phys_par?.find((candidate) => candidate.name === name)?.value ?? NaN);
|
||||||
|
return Number.isFinite(value) ? value : undefined;
|
||||||
|
};
|
||||||
|
const epochJd = Number(answer.orbit.epoch);
|
||||||
|
const epoch = new Date((epochJd - 2440587.5) * 86400000);
|
||||||
|
const diameterKm = physical('diameter');
|
||||||
|
const rotationPeriodHours = physical('rot_per');
|
||||||
|
|
||||||
|
return {
|
||||||
|
orbit: {
|
||||||
|
semiMajorAxisAu: element('a'),
|
||||||
|
eccentricity: element('e'),
|
||||||
|
inclinationDeg: element('i'),
|
||||||
|
longitudeOfAscendingNodeDeg: element('om'),
|
||||||
|
argumentOfPeriapsisDeg: element('w'),
|
||||||
|
meanAnomalyAtEpochDeg: element('ma'),
|
||||||
|
epochJd
|
||||||
|
},
|
||||||
|
rates: { meanMotionDegPerDay: element('n'), longitudeOfAscendingNodeDegPerDay: 0, argumentOfPeriapsisDegPerDay: 0 },
|
||||||
|
orbitSource: `JPL SBDB osculating elements, epoch ${epoch.getUTCFullYear()} ${MONTHS[epoch.getUTCMonth()]} ${epoch.getUTCDate()}`,
|
||||||
|
...(diameterKm !== undefined ? { radiusKm: diameterKm / 2 } : {}),
|
||||||
|
...(rotationPeriodHours !== undefined ? { rotationPeriodHours } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -66,6 +66,13 @@ describe('equilibriumTemperatureK', () => {
|
|||||||
expect(equilibriumTemperatureK(1, 1, 0.8)!).toBeLessThan(equilibriumTemperatureK(1, 1, 0)!);
|
expect(equilibriumTemperatureK(1, 1, 0.8)!).toBeLessThan(equilibriumTemperatureK(1, 1, 0)!);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('never falls below the microwave background, however far the star', () => {
|
||||||
|
// 2MASS J21252752-8138278 b is 7 493 AU out; around a star of a fiftieth of the Sun's output,
|
||||||
|
// starlight alone would hold it at 1.1 K.
|
||||||
|
expect(equilibriumTemperatureK(0.02, 7493)!).toBeGreaterThan(2.7255);
|
||||||
|
expect(equilibriumTemperatureK(0.02, 7493)!).toBeLessThan(3);
|
||||||
|
});
|
||||||
|
|
||||||
it('has no answer without a star or an orbit', () => {
|
it('has no answer without a star or an orbit', () => {
|
||||||
expect(equilibriumTemperatureK(null, 1)).toBeNull();
|
expect(equilibriumTemperatureK(null, 1)).toBeNull();
|
||||||
expect(equilibriumTemperatureK(1, undefined)).toBeNull();
|
expect(equilibriumTemperatureK(1, undefined)).toBeNull();
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ export function bulkDensityGramsPerCm3(massEarth: number | undefined, radiusEart
|
|||||||
return EARTH_DENSITY_G_PER_CM3 * (massEarth / Math.pow(radiusEarth, 3));
|
return EARTH_DENSITY_G_PER_CM3 * (massEarth / Math.pow(radiusEarth, 3));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The cosmic microwave background's temperature today (Fixsen 2009, ApJ 707, 916). */
|
||||||
|
const CMB_TEMPERATURE_K = 2.7255;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Equilibrium temperature in kelvin: the temperature at which a body re-radiates exactly the
|
* Equilibrium temperature in kelvin: the temperature at which a body re-radiates exactly the
|
||||||
* starlight it absorbs.
|
* starlight it absorbs.
|
||||||
@@ -97,6 +100,10 @@ export function bulkDensityGramsPerCm3(massEarth: number | undefined, radiusEart
|
|||||||
* push the real surface warmer — Venus's surface is 737 K against an equilibrium 232 K. It is
|
* 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
|
* 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.
|
* world is made of, which is what its surface looks like.
|
||||||
|
*
|
||||||
|
* The body also absorbs the cosmic microwave background, added as a second source in the same
|
||||||
|
* balance. It is nothing beside any star inside a few hundred AU, and it is why nothing in space is
|
||||||
|
* colder than 2.7 K: by starlight alone, the planet 7 493 AU from 2MASS J21252752-8138278 read 1 K.
|
||||||
*/
|
*/
|
||||||
export function equilibriumTemperatureK(
|
export function equilibriumTemperatureK(
|
||||||
luminositySolar: number | null | undefined,
|
luminositySolar: number | null | undefined,
|
||||||
@@ -106,7 +113,8 @@ export function equilibriumTemperatureK(
|
|||||||
if (!luminositySolar || !semiMajorAxisAu || luminositySolar <= 0 || semiMajorAxisAu <= 0) {
|
if (!luminositySolar || !semiMajorAxisAu || luminositySolar <= 0 || semiMajorAxisAu <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return SOLAR_EQUILIBRIUM_TEMPERATURE_K * Math.pow(luminositySolar, 0.25) * Math.pow(semiMajorAxisAu, -0.5) * Math.pow(1 - bondAlbedo, 0.25);
|
const starlit = SOLAR_EQUILIBRIUM_TEMPERATURE_K * Math.pow(luminositySolar, 0.25) * Math.pow(semiMajorAxisAu, -0.5) * Math.pow(1 - bondAlbedo, 0.25);
|
||||||
|
return Math.pow(starlit ** 4 + CMB_TEMPERATURE_K ** 4, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { meanElementsAt } from './kepler';
|
||||||
|
import { orbitalTermsOfPrimeMeridian, orientationAt, parsePckRotationalElements } from './rotational-elements';
|
||||||
|
import { MeanElementRates, OrbitalElements, RotationalElements } from '../models/body.model';
|
||||||
|
|
||||||
|
// Excerpts of pck00011.tpc as NAIF publishes it: prose, then data blocks.
|
||||||
|
const KERNEL = String.raw`KPL/PCK
|
||||||
|
|
||||||
|
The portion of the file preceding the first data block is treated
|
||||||
|
as a comment.
|
||||||
|
|
||||||
|
\begindata
|
||||||
|
|
||||||
|
BODY399_POLE_RA = ( 0. -0.641 0. )
|
||||||
|
BODY399_POLE_DEC = ( 90. -0.557 0. )
|
||||||
|
BODY399_PM = ( 190.147 360.9856235 0. )
|
||||||
|
|
||||||
|
\begintext
|
||||||
|
|
||||||
|
A data block starts with the \begindata token only when that token
|
||||||
|
sits on a line by itself, so BODY399_PM = ( 1 2 3 ) here is prose.
|
||||||
|
|
||||||
|
\begindata
|
||||||
|
|
||||||
|
BODY301_POLE_RA = ( 269.9949 0.0031 0. )
|
||||||
|
BODY301_POLE_DEC = ( 66.5392 0.0130 0. )
|
||||||
|
BODY301_PM = ( 38.3213 13.17635815 -1.4D-12 )
|
||||||
|
|
||||||
|
BODY899_POLE_RA = ( 299.36 0. 0. )
|
||||||
|
BODY899_POLE_DEC = ( 43.46 0. 0. )
|
||||||
|
BODY899_PM = ( 249.978 541.1397757 0. )
|
||||||
|
BODY899_NUT_PREC_RA = ( 0.70 0. 0. 0. 0. 0. 0. 0. )
|
||||||
|
BODY899_NUT_PREC_DEC = ( -0.51 0. 0. 0. 0. 0. 0. 0. )
|
||||||
|
BODY899_NUT_PREC_PM = ( -0.48 0. 0. 0. 0. 0. 0. 0. )
|
||||||
|
BODY8_NUT_PREC_ANGLES = ( 357.85 52.316
|
||||||
|
323.92 62606.6 )
|
||||||
|
|
||||||
|
BODY199_POLE_RA = ( 281.0103 -0.0328 0. )
|
||||||
|
BODY199_POLE_DEC = ( 61.4155 -0.0049 0. )
|
||||||
|
BODY199_PM = ( 329.5988 6.1385108 0. )
|
||||||
|
BODY199_NUT_PREC_RA = ( 0. 0. )
|
||||||
|
BODY199_NUT_PREC_DEC = ( 0. 0. )
|
||||||
|
BODY199_NUT_PREC_PM = ( 0.01067257
|
||||||
|
-0.00112309 )
|
||||||
|
BODY1_NUT_PREC_ANGLES = ( 174.7910857 0.14947253587500003E+06
|
||||||
|
349.5821714 0.29894507175000006E+06 )
|
||||||
|
|
||||||
|
BODY401_POLE_RA = ( 317.67071657 -0.10844326 0. )
|
||||||
|
BODY401_POLE_DEC = ( 52.88627266 -0.06134706 0. )
|
||||||
|
BODY401_PM = ( 35.18774440 1128.84475928
|
||||||
|
9.536137031212154e-09 )
|
||||||
|
BODY401_NUT_PREC_RA = ( -1.78428399 )
|
||||||
|
BODY401_NUT_PREC_DEC = ( -1.07516537 )
|
||||||
|
BODY401_NUT_PREC_PM = ( 1.42421769
|
||||||
|
-1.143 )
|
||||||
|
BODY4_MAX_PHASE_DEGREE = 2
|
||||||
|
BODY4_NUT_PREC_ANGLES = (
|
||||||
|
190.72646643 15917.10818695 0
|
||||||
|
189.63271560 41215158.18420050 12.711923222 )
|
||||||
|
|
||||||
|
\begintext
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe('parsePckRotationalElements', () => {
|
||||||
|
it('reads a pole and a prime meridian from the data blocks, not from the prose around them', () => {
|
||||||
|
expect(parsePckRotationalElements(KERNEL, 399)).toEqual({
|
||||||
|
elements: { poleRaDeg: [0, -0.641, 0], poleDecDeg: [90, -0.557, 0], primeMeridianDeg: [190.147, 360.9856235, 0] },
|
||||||
|
skippedDeg: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the exponent the Fortran way, as the Moon’s quadratic is written', () => {
|
||||||
|
expect(parsePckRotationalElements(KERNEL, 301)!.elements.primeMeridianDeg).toEqual([38.3213, 13.17635815, -1.4e-12]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pairs each periodic term with its system’s angle', () => {
|
||||||
|
expect(parsePckRotationalElements(KERNEL, 899)!.elements.terms).toEqual([{ angleDeg: [357.85, 52.316], ra: 0.7, dec: -0.51, pm: -0.48 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads angles to the degree the system states, Phobos’s quadratic among them', () => {
|
||||||
|
expect(parsePckRotationalElements(KERNEL, 401)!.elements.terms).toEqual([
|
||||||
|
{ angleDeg: [190.72646643, 15917.10818695, 0], ra: -1.78428399, dec: -1.07516537, pm: 1.42421769 },
|
||||||
|
{ angleDeg: [189.6327156, 41215158.1842005, 12.711923222], ra: 0, dec: 0, pm: -1.143 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves out a term under a hundredth of a degree, and says how large it was', () => {
|
||||||
|
const mercury = parsePckRotationalElements(KERNEL, 199)!;
|
||||||
|
|
||||||
|
expect(mercury.elements.terms).toEqual([{ angleDeg: [174.7910857, 149472.53587500003], ra: 0, dec: 0, pm: 0.01067257 }]);
|
||||||
|
expect(mercury.skippedDeg).toEqual([0.00112309]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives nothing for a body the kernel has no model for', () => {
|
||||||
|
expect(parsePckRotationalElements(KERNEL, 802)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('orientationAt', () => {
|
||||||
|
const J2000 = 2451545.0;
|
||||||
|
|
||||||
|
it('turns the prime meridian at its rate per day and moves the pole at its rate per century', () => {
|
||||||
|
const earth = parsePckRotationalElements(KERNEL, 399)!.elements;
|
||||||
|
|
||||||
|
const epoch = orientationAt(earth, J2000);
|
||||||
|
expect([epoch.poleRaDeg, epoch.poleDecDeg]).toEqual([0, 90]);
|
||||||
|
expect(epoch.primeMeridianDeg).toBeCloseTo(190.147, 9);
|
||||||
|
const century = orientationAt(earth, J2000 + 36525);
|
||||||
|
expect(century.poleRaDeg).toBeCloseTo(-0.641, 12);
|
||||||
|
expect(century.poleDecDeg).toBeCloseTo(90 - 0.557, 12);
|
||||||
|
expect(orientationAt(earth, J2000 + 1).primeMeridianDeg).toBeCloseTo(190.147 + 360.9856235 - 360, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a term as a sine to the right ascension and the meridian and a cosine to the declination', () => {
|
||||||
|
const neptune = parsePckRotationalElements(KERNEL, 899)!.elements;
|
||||||
|
const days = 9000;
|
||||||
|
const angle = ((357.85 + (52.316 * days) / 36525) * Math.PI) / 180;
|
||||||
|
const drawn = orientationAt(neptune, J2000 + days);
|
||||||
|
|
||||||
|
expect(drawn.poleRaDeg).toBeCloseTo(299.36 + 0.7 * Math.sin(angle), 12);
|
||||||
|
expect(drawn.poleDecDeg).toBeCloseTo(43.46 - 0.51 * Math.cos(angle), 12);
|
||||||
|
expect(drawn.primeMeridianDeg).toBeCloseTo((249.978 + 541.1397757 * days - 0.48 * Math.sin(angle)) % 360, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the quadratic in the meridian and in the angle, which is how Phobos falls inward', () => {
|
||||||
|
const phobos = parsePckRotationalElements(KERNEL, 401)!.elements;
|
||||||
|
const days = 36525;
|
||||||
|
const first = (190.72646643 + 15917.10818695) * (Math.PI / 180);
|
||||||
|
const second = (189.6327156 + 41215158.1842005 + 12.711923222) * (Math.PI / 180);
|
||||||
|
const expected = 35.1877444 + 1128.84475928 * days + 9.536137031212154e-9 * days * days + 1.42421769 * Math.sin(first) - 1.143 * Math.sin(second);
|
||||||
|
|
||||||
|
expect(orientationAt(phobos, J2000 + days).primeMeridianDeg).toBeCloseTo(((expected % 360) + 360) % 360, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('orbitalTermsOfPrimeMeridian', () => {
|
||||||
|
const J2000 = 2451545.0;
|
||||||
|
// Mimas's and Phobos's rows and W as pck00011.tpc and JPL's table give them, with each mean
|
||||||
|
// motion set to W's rate, so that only the terms can part the two.
|
||||||
|
const MIMAS: RotationalElements = {
|
||||||
|
poleRaDeg: [40.66, -0.036],
|
||||||
|
poleDecDeg: [83.52, -0.004],
|
||||||
|
primeMeridianDeg: [333.46, 381.994555, 0],
|
||||||
|
terms: [
|
||||||
|
{ angleDeg: [177.4, -36505.5], ra: 13.56, dec: -1.53, pm: -13.48 },
|
||||||
|
{ angleDeg: [316.45, 506.2], ra: 0, dec: 0, pm: -44.85 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const PHOBOS: RotationalElements = {
|
||||||
|
poleRaDeg: [317.67071657, -0.10844326, 0],
|
||||||
|
poleDecDeg: [52.88627266, -0.06134706, 0],
|
||||||
|
primeMeridianDeg: [35.1877444, 1128.84475928, 9.536137031212154e-9]
|
||||||
|
};
|
||||||
|
const orbit = (epochJd: number): OrbitalElements => ({
|
||||||
|
semiMajorAxisAu: 0.001,
|
||||||
|
eccentricity: 0.02,
|
||||||
|
inclinationDeg: 1.5,
|
||||||
|
longitudeOfAscendingNodeDeg: 170,
|
||||||
|
argumentOfPeriapsisDeg: 60,
|
||||||
|
meanAnomalyAtEpochDeg: 10,
|
||||||
|
epochJd
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The moon's mean longitude less W, which a locked moon holds still whatever the date. */
|
||||||
|
function lead(elements: RotationalElements, epochJd: number, angleRate: number | undefined, jd: number): number {
|
||||||
|
const { meanAnomalyTerms, meanMotionDegPerDay, meanAnomalyDeg } = orbitalTermsOfPrimeMeridian(elements, epochJd, angleRate);
|
||||||
|
const start = orbit(epochJd);
|
||||||
|
const rates: MeanElementRates = {
|
||||||
|
meanMotionDegPerDay: elements.primeMeridianDeg[1] + meanMotionDegPerDay,
|
||||||
|
longitudeOfAscendingNodeDegPerDay: -1,
|
||||||
|
argumentOfPeriapsisDegPerDay: 2,
|
||||||
|
meanAnomalyTerms
|
||||||
|
};
|
||||||
|
const moved = meanElementsAt({ ...start, meanAnomalyAtEpochDeg: start.meanAnomalyAtEpochDeg + meanAnomalyDeg }, rates, jd);
|
||||||
|
const longitude = moved.longitudeOfAscendingNodeDeg + moved.argumentOfPeriapsisDeg + moved.meanAnomalyAtEpochDeg;
|
||||||
|
// W with the pole's nodding term left out: that one is the pole's, not the orbit's.
|
||||||
|
const w = orientationAt({ ...elements, terms: elements.terms?.filter((term) => term.angleDeg[1] === angleRate) }, jd).primeMeridianDeg;
|
||||||
|
return (((longitude - w) % 360) + 540) % 360 - 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('moves Mimas along its orbit by the libration its W carries, over the 71 years it takes', () => {
|
||||||
|
const atEpoch = lead(MIMAS, J2000, 506.2, J2000);
|
||||||
|
for (const years of [-130, -40, 17.8, 35.5, 100]) {
|
||||||
|
expect(lead(MIMAS, J2000, 506.2, J2000 + years * 365.25)).toBeCloseTo(atEpoch, 8);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('speeds Phobos up by the tidal quadratic its W carries about J2000, from a row whose epoch is 1950', () => {
|
||||||
|
const epoch = 2433282.5;
|
||||||
|
const atEpoch = lead(PHOBOS, epoch, undefined, epoch);
|
||||||
|
for (const years of [-150, 0, 50, 100, 150, 400]) {
|
||||||
|
expect(lead(PHOBOS, epoch, undefined, J2000 + years * 365.25)).toBeCloseTo(atEpoch, 6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a term W does not carry', () => {
|
||||||
|
expect(() => orbitalTermsOfPrimeMeridian(PHOBOS, J2000, 506.2)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { RotationalElements } from '../models/body.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the IAU WGCCRE 2015 rotational elements from NAIF's text kernel `pck00011.tpc`, which the
|
||||||
|
* ETL fetches (see `tools/etl/lib/pck.ts`), and evaluates them at a date.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const J2000_JD = 2451545.0;
|
||||||
|
const DAYS_PER_JULIAN_CENTURY = 36525;
|
||||||
|
const DEG_TO_RAD = Math.PI / 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The smallest periodic term kept, in degrees. A term turns the body, or tips its pole, by at most
|
||||||
|
* its amplitude, and the largest a body is ever drawn is Jupiter filling the screen at 641 px of
|
||||||
|
* radius, where 0.01 degrees moves a point on its surface by 0.11 px. In `pck00011.tpc` this
|
||||||
|
* leaves out 32 terms: Mercury's four smaller librations (0.0011 degrees and less), eight of the
|
||||||
|
* Moon's thirteen (0.0072 and less), the thirteen short-period terms of Mars's pole and meridian
|
||||||
|
* (0.00024 and less; its three 0.42-1.59 degree long-period ones stay), one of Phobos's (0.0063),
|
||||||
|
* Jupiter's five (0.0022 and less) and one of Europa's (0.009). Mimas's 44.85-degree libration,
|
||||||
|
* Triton's 32-degree precession and Miranda's 4.4 are kept, down to Triton's 0.01.
|
||||||
|
*/
|
||||||
|
export const MIN_PERIODIC_TERM_DEG = 0.01;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every `NAME = ( values )` assignment in the kernel's data blocks. A data block runs from a line
|
||||||
|
* holding only `\begindata` to one holding only `\begintext`; the kernel's own prose mentions both
|
||||||
|
* tokens mid-sentence, which is why they are only read alone on a line. Exponents are written
|
||||||
|
* the Fortran way, `-1.4D-12`.
|
||||||
|
*/
|
||||||
|
function pckVariables(text: string): Map<string, number[]> {
|
||||||
|
const data = text
|
||||||
|
.split(/^\s*\\begindata\s*$/m)
|
||||||
|
.slice(1)
|
||||||
|
.map((block) => block.split(/^\s*\\begintext\s*$/m)[0])
|
||||||
|
.join('\n');
|
||||||
|
const variables = new Map<string, number[]>();
|
||||||
|
for (const [, name, value] of data.matchAll(/(\w+)\s*=\s*(\([^)]*\)|\S+)/g)) {
|
||||||
|
variables.set(
|
||||||
|
name,
|
||||||
|
value
|
||||||
|
.replace(/[()]/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((token) => Number(token.replace(/d/i, 'e')))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return variables;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One body's elements, by its NAIF id: 399 for Earth, 301 for the Moon, 2000001 for Ceres.
|
||||||
|
* Undefined where the kernel has none.
|
||||||
|
*
|
||||||
|
* The periodic terms' angles belong to the planet's whole system, `BODY5_NUT_PREC_ANGLES` for
|
||||||
|
* Jupiter and its moons, each a polynomial in T whose degree `BODYn_MAX_PHASE_DEGREE` gives: 1
|
||||||
|
* unless stated, 2 for Mars, where Phobos's angle carries the tidal acceleration that is drawing
|
||||||
|
* it in. A term is kept if any of its three amplitudes reaches {@link MIN_PERIODIC_TERM_DEG};
|
||||||
|
* the largest amplitude of each term left out comes back in `skippedDeg`, for the ETL to say so.
|
||||||
|
*/
|
||||||
|
export function parsePckRotationalElements(text: string, naifId: number): { elements: RotationalElements; skippedDeg: number[] } | undefined {
|
||||||
|
const variables = pckVariables(text);
|
||||||
|
const poleRaDeg = variables.get(`BODY${naifId}_POLE_RA`);
|
||||||
|
const poleDecDeg = variables.get(`BODY${naifId}_POLE_DEC`);
|
||||||
|
const primeMeridianDeg = variables.get(`BODY${naifId}_PM`);
|
||||||
|
if (!poleRaDeg || !poleDecDeg || !primeMeridianDeg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (![...poleRaDeg, ...poleDecDeg, ...primeMeridianDeg].every(Number.isFinite)) {
|
||||||
|
throw new Error(`Body ${naifId}'s pole or prime meridian did not parse.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ra = variables.get(`BODY${naifId}_NUT_PREC_RA`) ?? [];
|
||||||
|
const dec = variables.get(`BODY${naifId}_NUT_PREC_DEC`) ?? [];
|
||||||
|
const pm = variables.get(`BODY${naifId}_NUT_PREC_PM`) ?? [];
|
||||||
|
const system = naifId < 1000 ? Math.floor(naifId / 100) : undefined;
|
||||||
|
const angles = system === undefined ? [] : (variables.get(`BODY${system}_NUT_PREC_ANGLES`) ?? []);
|
||||||
|
const coefficients = (variables.get(`BODY${system}_MAX_PHASE_DEGREE`)?.[0] ?? 1) + 1;
|
||||||
|
|
||||||
|
const terms: NonNullable<RotationalElements['terms']> = [];
|
||||||
|
const skippedDeg: number[] = [];
|
||||||
|
for (let index = 0; index < Math.max(ra.length, dec.length, pm.length); index++) {
|
||||||
|
const term = { ra: ra[index] ?? 0, dec: dec[index] ?? 0, pm: pm[index] ?? 0 };
|
||||||
|
const largest = Math.max(Math.abs(term.ra), Math.abs(term.dec), Math.abs(term.pm));
|
||||||
|
if (largest === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (largest < MIN_PERIODIC_TERM_DEG) {
|
||||||
|
skippedDeg.push(largest);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const angleDeg = angles.slice(index * coefficients, (index + 1) * coefficients);
|
||||||
|
if (angleDeg.length !== coefficients || !angleDeg.every(Number.isFinite)) {
|
||||||
|
throw new Error(`Body ${naifId}'s periodic term ${index + 1} has no angle among BODY${system}_NUT_PREC_ANGLES.`);
|
||||||
|
}
|
||||||
|
terms.push({ angleDeg, ...term });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: { poleRaDeg, poleDecDeg, primeMeridianDeg, ...(terms.length > 0 ? { terms } : {}) },
|
||||||
|
skippedDeg
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Sun's, which is no `BodyRecord` but the system's star marker: NAIF body 10 in pck00011.tpc,
|
||||||
|
* the WGCCRE 2015 pole at RA 286.13, Dec 63.87 and W = 84.176 + 14.1844 d, a sidereal day of 25.38
|
||||||
|
* days at the Carrington latitude. The ETL checks them against the kernel.
|
||||||
|
*/
|
||||||
|
export const SUN_ROTATIONAL_ELEMENTS: RotationalElements = { poleRaDeg: [286.13, 0, 0], poleDecDeg: [63.87, 0, 0], primeMeridianDeg: [84.176, 14.1844, 0] };
|
||||||
|
|
||||||
|
function polynomial(coefficients: readonly number[], x: number): number {
|
||||||
|
return (coefficients[0] ?? 0) + (coefficients[1] ?? 0) * x + (coefficients[2] ?? 0) * x * x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pole's right ascension and declination and the prime meridian W, in degrees, at a TDB Julian date. */
|
||||||
|
export function orientationAt(elements: RotationalElements, jdTdb: number): { poleRaDeg: number; poleDecDeg: number; primeMeridianDeg: number } {
|
||||||
|
const days = jdTdb - J2000_JD;
|
||||||
|
const centuries = days / DAYS_PER_JULIAN_CENTURY;
|
||||||
|
let poleRaDeg = polynomial(elements.poleRaDeg, centuries);
|
||||||
|
let poleDecDeg = polynomial(elements.poleDecDeg, centuries);
|
||||||
|
let primeMeridianDeg = polynomial(elements.primeMeridianDeg, days);
|
||||||
|
for (const term of elements.terms ?? []) {
|
||||||
|
const angle = polynomial(term.angleDeg, centuries) * DEG_TO_RAD;
|
||||||
|
poleRaDeg += term.ra * Math.sin(angle);
|
||||||
|
poleDecDeg += term.dec * Math.cos(angle);
|
||||||
|
primeMeridianDeg += term.pm * Math.sin(angle);
|
||||||
|
}
|
||||||
|
return { poleRaDeg, poleDecDeg, primeMeridianDeg: ((primeMeridianDeg % 360) + 360) % 360 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a locked moon's W says of its going round that a row of mean elements leaves out, as terms
|
||||||
|
* of that row. W follows the moon's mean longitude, so a term of W that is the moon running ahead
|
||||||
|
* of and behind its mean motion, rather than its pole nodding, is its orbit's too. Two are here,
|
||||||
|
* and JPL's satellite table has a column for neither: Mimas's -44.85 degrees and Tethys's +2.23 on
|
||||||
|
* the angle that turns 506.2 degrees a century, the 71-year libration of their 4:2 resonance, and
|
||||||
|
* Phobos's quadratic, 12.72 degrees per century squared about J2000, the tidal acceleration
|
||||||
|
* drawing it in. Carried by W and not by the orbit, they left the drawn Mimas up to 45 degrees from
|
||||||
|
* where Horizons has it and its face as far from Saturn, and Phobos 11 degrees out by 2100.
|
||||||
|
*
|
||||||
|
* `angleRateDegPerCentury` names the term by its angle's rate; W's quadratic, where it has one, is
|
||||||
|
* always taken. Both come back about `epochJd`, which is where `meanAnomalyTerms` counts T from:
|
||||||
|
* the sine as its `c` and `s`, and the quadratic re-centred from J2000 onto that epoch, as `b` plus
|
||||||
|
* what the re-centring adds to the mean motion and to the mean anomaly at the epoch.
|
||||||
|
*/
|
||||||
|
export function orbitalTermsOfPrimeMeridian(
|
||||||
|
elements: RotationalElements,
|
||||||
|
epochJd: number,
|
||||||
|
angleRateDegPerCentury?: number
|
||||||
|
): { meanAnomalyTerms: { b: number; c: number; s: number; f: number }; meanMotionDegPerDay: number; meanAnomalyDeg: number } {
|
||||||
|
const epochCenturies = (epochJd - J2000_JD) / DAYS_PER_JULIAN_CENTURY;
|
||||||
|
// W turns clockwise about the pole the IAU names where its rate is negative; the orbit does not.
|
||||||
|
const sense = Math.sign(elements.primeMeridianDeg[1]);
|
||||||
|
const quadratic = sense * (elements.primeMeridianDeg[2] ?? 0) * DAYS_PER_JULIAN_CENTURY * DAYS_PER_JULIAN_CENTURY;
|
||||||
|
let sine = { c: 0, s: 0, f: 0 };
|
||||||
|
if (angleRateDegPerCentury !== undefined) {
|
||||||
|
const term = elements.terms?.find((candidate) => candidate.angleDeg[1] === angleRateDegPerCentury);
|
||||||
|
if (!term || (term.angleDeg[2] ?? 0) !== 0) {
|
||||||
|
throw new Error(`No term of W turns linearly at ${angleRateDegPerCentury} degrees a century.`);
|
||||||
|
}
|
||||||
|
const phase = (term.angleDeg[0] + term.angleDeg[1] * epochCenturies) * DEG_TO_RAD;
|
||||||
|
sine = { c: sense * term.pm * Math.sin(phase), s: sense * term.pm * Math.cos(phase), f: term.angleDeg[1] };
|
||||||
|
}
|
||||||
|
// q (T + T0)², T from the epoch and T0 the epoch from J2000, is q T² + 2 q T0 T + q T0².
|
||||||
|
return {
|
||||||
|
meanAnomalyTerms: { b: quadratic, ...sine },
|
||||||
|
meanMotionDegPerDay: (2 * quadratic * epochCenturies) / DAYS_PER_JULIAN_CENTURY,
|
||||||
|
meanAnomalyDeg: quadratic * epochCenturies * epochCenturies
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { parseSpectralClass, SPECTRAL_CLASSES, spectralTypeToColorIndex } from './spectral';
|
import { dwarfSequenceAtColor, dwarfSequenceAtTemperature, dwarfSequenceAtType, isGiant, parseSpectralClass, spectralClassification, SPECTRAL_CLASSES, spectralTypeFromColor, spectralTypeToColorIndex, temperatureToColorIndex } from './spectral';
|
||||||
|
|
||||||
describe('parseSpectralClass', () => {
|
describe('parseSpectralClass', () => {
|
||||||
it('reads a clean class and subclass', () => {
|
it('reads a clean class and subclass', () => {
|
||||||
@@ -46,6 +46,20 @@ describe('parseSpectralClass', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isGiant', () => {
|
||||||
|
it('reads luminosity classes I to III off the primary, the giant and supergiant prefixes, and carbon and S stars', () => {
|
||||||
|
for (const type of ['M1Ib + B2.5V', 'K5III', 'M2II-IIIvar', 'C7Iab', 'K0IIIb', 'gK0', 'cM2', 'N5', 'Ce+', 'S57:']) {
|
||||||
|
expect(isGiant(type), type).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves dwarfs, subgiants, a dwarf with a giant companion and the unclassified alone', () => {
|
||||||
|
for (const type of ['G2V', 'B2IV', 'F0IVn', 'M5Ve', 'K1V + M3III', 'g-k', 'Unknown', 'DA', '']) {
|
||||||
|
expect(isGiant(type), type).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('spectralTypeToColorIndex', () => {
|
describe('spectralTypeToColorIndex', () => {
|
||||||
it('places the Sun near its real B-V of 0.65', () => {
|
it('places the Sun near its real B-V of 0.65', () => {
|
||||||
expect(spectralTypeToColorIndex('G2V')).toBeCloseTo(0.626, 2);
|
expect(spectralTypeToColorIndex('G2V')).toBeCloseTo(0.626, 2);
|
||||||
@@ -83,3 +97,119 @@ describe('spectralTypeToColorIndex', () => {
|
|||||||
expect(spectralTypeToColorIndex('')).toBeNull();
|
expect(spectralTypeToColorIndex('')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('temperatureToColorIndex', () => {
|
||||||
|
it("puts the Sun's temperature at its own B-V and a cool dwarf where the dwarf sequence has it", () => {
|
||||||
|
expect(temperatureToColorIndex(5772)).toBeCloseTo(0.65, 2);
|
||||||
|
// Two thirds of the way from M2 (3 560 K, B−V 1.505) to M2.5 (3 470 K, 1.522).
|
||||||
|
expect(temperatureToColorIndex(3500)).toBeCloseTo(1.5163, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads back as the temperature it came from, so the correction is the one at that temperature', () => {
|
||||||
|
for (const temperatureK of [31400, 12000, 7000, 5772, 4000, 3500, 3157, 2566, 2420]) {
|
||||||
|
expect(dwarfSequenceAtColor(temperatureToColorIndex(temperatureK))!.temperatureK).toBeCloseTo(temperatureK, 6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no answer outside the table, nor for a temperature that is not one', () => {
|
||||||
|
for (const temperatureK of [580, 2419, 31401, 50000, 0, Number.NaN]) {
|
||||||
|
expect(temperatureToColorIndex(temperatureK)).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('spectralTypeFromColor', () => {
|
||||||
|
it("reads the Sun's type off either colour", () => {
|
||||||
|
expect(spectralTypeFromColor(0.65, 'B-V')).toBe('G2');
|
||||||
|
expect(spectralTypeFromColor(0.82, 'BP-RP')).toBe('G2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a red dwarf the way it was classified', () => {
|
||||||
|
// TRAPPIST-1 is M8 V, and Gaia has it at BP−RP 4.90; Proxima is M5.5 Ve at B−V 1.81.
|
||||||
|
expect(spectralTypeFromColor(4.902, 'BP-RP')).toBe('M8');
|
||||||
|
expect(spectralTypeFromColor(1.807, 'B-V')).toBe('M5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not read one colour as the other', () => {
|
||||||
|
// 1.43 is a K5 dwarf in BP−RP and an M0 in B−V.
|
||||||
|
expect(spectralTypeFromColor(1.43, 'BP-RP')).toBe('K5');
|
||||||
|
expect(spectralTypeFromColor(1.43, 'B-V')).toBe('M0');
|
||||||
|
expect(spectralTypeFromColor(1.43)).toBe('M0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no answer past either end of the table, nor without a colour', () => {
|
||||||
|
expect(spectralTypeFromColor(-0.35, 'B-V')).toBeNull();
|
||||||
|
expect(spectralTypeFromColor(-0.15, 'BP-RP')).toBeNull();
|
||||||
|
expect(spectralTypeFromColor(5.5, 'BP-RP')).toBeNull();
|
||||||
|
expect(spectralTypeFromColor(null, 'B-V')).toBeNull();
|
||||||
|
expect(spectralTypeFromColor(-0.301, 'B-V')).toBe('B0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('spectralClassification', () => {
|
||||||
|
it("gives the catalogue's type, else the colour's marked as an estimate, else nothing", () => {
|
||||||
|
expect(spectralClassification({ spectralType: 'M5Ve', colorIndex: 1.807, colorSystem: 'B-V' })).toBe('M5Ve');
|
||||||
|
expect(spectralClassification({ spectralType: 'Unknown', colorIndex: 4.902, colorSystem: 'BP-RP' })).toBe('~M8');
|
||||||
|
expect(spectralClassification({ spectralType: 'Unknown', colorIndex: null })).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dwarfSequenceAtColor', () => {
|
||||||
|
it("puts the Sun's colour in either system at the Sun's temperature and correction", () => {
|
||||||
|
for (const [colour, system] of [[0.65, 'B-V'], [0.823, 'BP-RP']] as const) {
|
||||||
|
const point = dwarfSequenceAtColor(colour, system)!;
|
||||||
|
expect(point.temperatureK).toBeCloseTo(5770, 0);
|
||||||
|
expect(point.bolometricCorrectionV).toBeCloseTo(-0.085, 3);
|
||||||
|
expect(point.gMinusV).toBeCloseTo(-0.165, 3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interpolates between the two types a colour falls between', () => {
|
||||||
|
// Halfway from M1.5 (B−V 1.495, 3 620 K, −1.50) to M2 (1.505, 3 560 K, −1.62).
|
||||||
|
const point = dwarfSequenceAtColor(1.5, 'B-V')!;
|
||||||
|
expect(point.temperatureK).toBeCloseTo(3590, 6);
|
||||||
|
expect(point.bolometricCorrectionV).toBeCloseTo(-1.56, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no answer past either end of the table, and no G−V where none is tabulated', () => {
|
||||||
|
expect(dwarfSequenceAtColor(2.2, 'B-V')).toBeNull();
|
||||||
|
expect(dwarfSequenceAtColor(-0.15, 'BP-RP')).toBeNull();
|
||||||
|
expect(dwarfSequenceAtColor(null)).toBeNull();
|
||||||
|
expect(dwarfSequenceAtColor(-0.29, 'B-V')!.gMinusV).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the row at the end a colour is past, when asked to', () => {
|
||||||
|
expect(dwarfSequenceAtColor(2.2, 'B-V', true)).toEqual({ bMinusV: 2.16, temperatureK: 2420, bolometricCorrectionV: -5.78, gMinusV: -3.09 });
|
||||||
|
expect(dwarfSequenceAtColor(5.3, 'BP-RP', true)).toEqual({ bMinusV: 2.16, temperatureK: 2420, bolometricCorrectionV: -5.78, gMinusV: -3.09 });
|
||||||
|
expect(dwarfSequenceAtColor(-0.4, 'B-V', true)).toEqual({ bMinusV: -0.301, temperatureK: 31400, bolometricCorrectionV: -2.99, gMinusV: null });
|
||||||
|
expect(dwarfSequenceAtColor(null, 'B-V', true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a white dwarf bluer than BP−RP's end at the temperature measured at its colour, and the table's correction there", () => {
|
||||||
|
// Gentile Fusillo et al. (2021): 15 369 K at −0.15, where B9's row had 10 700; between B6 and B5.
|
||||||
|
const point = dwarfSequenceAtColor(-0.15, 'BP-RP', true)!;
|
||||||
|
expect(point.temperatureK).toBeCloseTo(15369, 6);
|
||||||
|
expect(point.bolometricCorrectionV).toBeCloseTo(-1.13 - 0.21 * (869 / 1200), 6);
|
||||||
|
expect(dwarfSequenceAtColor(-0.13, 'BP-RP', true)!.temperatureK).toBeCloseTo(15369 - 4669 * (2 / 3), 6);
|
||||||
|
expect(dwarfSequenceAtColor(-0.6, 'BP-RP', true)!.temperatureK).toBeCloseTo(28585, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dwarfSequenceAtType', () => {
|
||||||
|
it("reads an O type off Mamajek's O rows, which carry no colour, and any other off its own row", () => {
|
||||||
|
expect(dwarfSequenceAtType('O7.5Iab:')).toEqual({ bMinusV: null, temperatureK: 36100, bolometricCorrectionV: -3.33, gMinusV: null });
|
||||||
|
expect(dwarfSequenceAtType('B8Ia')!.temperatureK).toBe(12300);
|
||||||
|
expect(dwarfSequenceAtType('O9.7')!.temperatureK).toBeCloseTo(31900 - 500 * (0.2 / 0.5), 6);
|
||||||
|
expect(dwarfSequenceAtType('M9')!.temperatureK).toBe(2420);
|
||||||
|
expect(dwarfSequenceAtType('Unknown')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dwarfSequenceAtTemperature', () => {
|
||||||
|
it("is a type's own row at its temperature, between two rows between them, and the end row past either end", () => {
|
||||||
|
expect(dwarfSequenceAtTemperature(5770)).toEqual({ bMinusV: 0.65, temperatureK: 5770, bolometricCorrectionV: -0.085, gMinusV: -0.165 });
|
||||||
|
expect(dwarfSequenceAtTemperature(3615).bolometricCorrectionV).toBeCloseTo(-1.51, 6);
|
||||||
|
expect(dwarfSequenceAtTemperature(50000).temperatureK).toBe(31400);
|
||||||
|
expect(dwarfSequenceAtTemperature(1000).temperatureK).toBe(2420);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -69,6 +69,20 @@ export function parseSpectralClass(
|
|||||||
return { spectralClass, subclass };
|
return { spectralClass, subclass };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Luminosity class I (with Ia, Iab, Ib), II or III, not the I of a IV. */
|
||||||
|
const GIANT_LUMINOSITY_CLASS = /(?<![IV])(?:III|II|I)(?![IV])/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a spectral type says its star is a giant or supergiant: luminosity class I to III,
|
||||||
|
* HYG's `g` or `c` prefix, or a carbon or S star (C, N, R, S), which are all giants on the
|
||||||
|
* asymptotic branch whether or not a class is given. Read off the primary only — Antares is
|
||||||
|
* `M1Ib + B2.5V`.
|
||||||
|
*/
|
||||||
|
export function isGiant(spectralType: string | null | undefined): boolean {
|
||||||
|
const primary = (spectralType ?? '').split('+')[0].trim();
|
||||||
|
return /^(?:[gc][OBAFGKM]|[CNRS])/.test(primary) || GIANT_LUMINOSITY_CLASS.test(primary);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Approximate B-V colour index for a spectral type, interpolating between the class anchors by
|
* 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
|
* subclass. Returns `null` when no class can be recognised, which is the honest answer for the
|
||||||
@@ -87,3 +101,196 @@ export function spectralTypeToColorIndex(spectralType: string | null | undefined
|
|||||||
|
|
||||||
return from + (to - from) * (subclass / 10);
|
return from + (to - from) * (subclass / 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B-V colour index for an effective temperature, for stars the Exoplanet Archive gives a
|
||||||
|
* temperature but no B magnitude: the dwarf sequence below read the other way, interpolated
|
||||||
|
* between the two types the temperature falls between, so that the correction and the temperature
|
||||||
|
* read back off the colour are the table's at that temperature. Ballesteros' blackbody fit, used
|
||||||
|
* before, runs 0.1 to 0.2 redder than the table below 3 800 K, and the colour it gave was read on
|
||||||
|
* the table: 3 500 K came back as 3 102 K with a correction 1.15 magnitudes too large, and the 57
|
||||||
|
* hosts placed this way were off the archive's own luminosity by 0.23 dex at the median. `null`
|
||||||
|
* outside the table, 2 420 to 31 400 K, rather than a colour clamped to its end — CFBDSIR
|
||||||
|
* J145829+101343, a 580 K brown dwarf, read as B−V 2.00 and "~M6".
|
||||||
|
*/
|
||||||
|
export function temperatureToColorIndex(temperatureK: number): number | null {
|
||||||
|
const [hottest, coolest] = [ROWS_WITH_COLOUR[1][0], DWARF_SEQUENCE[DWARF_SEQUENCE.length - 1]];
|
||||||
|
return temperatureK <= hottest[3] && temperatureK >= coolest[3] ? dwarfSequenceAtTemperature(temperatureK).bMinusV : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mean dwarf sequence: B−V, Gaia BP−RP, effective temperature (K), bolometric correction to V
|
||||||
|
* and Gaia G−V by spectral type, from Pecaut & Mamajek (2013, ApJS 208, 9, table 5) as Mamajek
|
||||||
|
* maintains it online (version 2022.04.16), where the Gaia columns were added. From O3 to M8.5,
|
||||||
|
* past which BP−RP turns back. The O rows, read by type only, carry no colour: B−V stops telling
|
||||||
|
* types apart there, the whole O sequence spanning 0.03 of it. BP−RP starts at B9, the bluest it
|
||||||
|
* is tabulated for, and G−V at B1.5.
|
||||||
|
*/
|
||||||
|
type SequenceRow = readonly [string, number | null, number | null, number, number, number | null];
|
||||||
|
|
||||||
|
const DWARF_SEQUENCE: readonly SequenceRow[] = [
|
||||||
|
['O3', null, null, 44900, -4.01, null], ['O4', null, null, 42900, -3.89, null], ['O5', null, null, 41400, -3.76, null],
|
||||||
|
['O5.5', null, null, 40500, -3.67, null], ['O6', null, null, 39500, -3.57, null], ['O6.5', null, null, 38300, -3.49, null],
|
||||||
|
['O7', null, null, 37100, -3.41, null], ['O7.5', null, null, 36100, -3.33, null], ['O8', null, null, 35100, -3.24, null],
|
||||||
|
['O8.5', null, null, 34300, -3.18, null], ['O9', null, null, 33300, -3.11, null], ['O9.5', null, null, 31900, -3.01, null],
|
||||||
|
['B0', -0.301, null, 31400, -2.99, null], ['B0.5', -0.289, null, 29000, -2.83, null], ['B1', -0.278, null, 26000, -2.58, null],
|
||||||
|
['B1.5', -0.252, null, 24500, -2.44, -0.021], ['B2', -0.215, null, 20600, -2.03, -0.008], ['B2.5', -0.198, null, 18500, -1.77, -0.003],
|
||||||
|
['B3', -0.178, null, 17000, -1.54, 0.001], ['B4', -0.165, null, 16400, -1.49, 0.004], ['B5', -0.156, null, 15700, -1.34, 0.007],
|
||||||
|
['B6', -0.14, null, 14500, -1.13, 0.01], ['B7', -0.128, null, 14000, -1.05, 0.012], ['B8', -0.109, null, 12300, -0.73, 0.016],
|
||||||
|
['B9', -0.07, -0.12, 10700, -0.42, 0.018], ['B9.5', -0.05, -0.087, 10400, -0.36, 0.017], ['A0', 0, -0.037, 9700, -0.21, 0.015],
|
||||||
|
['A1', 0.035, 0.005, 9300, -0.14, 0.01], ['A2', 0.07, 0.068, 8800, -0.07, 0], ['A3', 0.1, 0.11, 8600, -0.04, -0.005],
|
||||||
|
['A4', 0.14, 0.166, 8250, -0.02, -0.01], ['A5', 0.16, 0.194, 8100, 0, -0.015], ['A6', 0.185, 0.222, 7910, 0.005, -0.02],
|
||||||
|
['A7', 0.21, 0.263, 7760, 0.01, -0.03], ['A8', 0.25, 0.32, 7590, 0.02, -0.04], ['A9', 0.27, 0.327, 7400, 0.02, -0.05],
|
||||||
|
['F0', 0.295, 0.377, 7220, 0.01, -0.06], ['F1', 0.33, 0.434, 7020, 0.005, -0.07], ['F2', 0.37, 0.49, 6820, -0.005, -0.08],
|
||||||
|
['F3', 0.39, 0.518, 6750, -0.01, -0.09], ['F4', 0.41, 0.546, 6670, -0.015, -0.1], ['F5', 0.44, 0.587, 6550, -0.02, -0.11],
|
||||||
|
['F6', 0.486, 0.64, 6350, -0.03, -0.13], ['F7', 0.5, 0.67, 6280, -0.035, -0.14], ['F8', 0.53, 0.694, 6180, -0.04, -0.15],
|
||||||
|
['F9', 0.56, 0.719, 6050, -0.05, -0.145], ['F9.5', 0.58, 0.767, 5990, -0.06, -0.155], ['G0', 0.595, 0.784, 5930, -0.065, -0.155],
|
||||||
|
['G1', 0.622, 0.803, 5860, -0.073, -0.158], ['G2', 0.65, 0.823, 5770, -0.085, -0.165], ['G3', 0.66, 0.832, 5720, -0.095, -0.167],
|
||||||
|
['G4', 0.67, 0.841, 5680, -0.1, -0.173], ['G5', 0.68, 0.85, 5660, -0.105, -0.179], ['G6', 0.7, 0.869, 5600, -0.115, -0.186],
|
||||||
|
['G7', 0.71, 0.88, 5550, -0.125, -0.194], ['G8', 0.73, 0.9, 5480, -0.14, -0.202], ['G9', 0.775, 0.95, 5380, -0.16, -0.21],
|
||||||
|
['K0', 0.816, 0.983, 5270, -0.195, -0.227], ['K1', 0.857, 1.01, 5170, -0.23, -0.25], ['K2', 0.884, 1.1, 5100, -0.26, -0.27],
|
||||||
|
['K3', 0.99, 1.21, 4830, -0.375, -0.32], ['K4', 1.09, 1.34, 4600, -0.52, -0.42], ['K5', 1.15, 1.43, 4440, -0.63, -0.44],
|
||||||
|
['K6', 1.24, 1.53, 4300, -0.75, -0.51], ['K7', 1.34, 1.7, 4100, -0.93, -0.58], ['K8', 1.363, 1.73, 3990, -1.03, -0.625],
|
||||||
|
['K9', 1.4, 1.79, 3930, -1.07, -0.66], ['M0', 1.42, 1.84, 3850, -1.15, -0.7], ['M0.5', 1.445, 1.97, 3770, -1.29, -0.76],
|
||||||
|
['M1', 1.485, 2.09, 3660, -1.42, -0.82], ['M1.5', 1.495, 2.13, 3620, -1.5, -0.87], ['M2', 1.505, 2.23, 3560, -1.62, -0.925],
|
||||||
|
['M2.5', 1.522, 2.39, 3470, -1.78, -1.02], ['M3', 1.53, 2.5, 3430, -1.93, -1.1], ['M3.5', 1.6, 2.78, 3270, -2.28, -1.28],
|
||||||
|
['M4', 1.65, 2.94, 3210, -2.51, -1.4], ['M4.5', 1.69, 3.16, 3110, -2.84, -1.54], ['M5', 1.83, 3.35, 3060, -3.11, -1.7],
|
||||||
|
['M5.5', 1.94, 3.71, 2930, -3.58, -1.95], ['M6', 2.01, 4.16, 2810, -4.13, -2.37], ['M6.5', 2.07, 4.5, 2740, -4.62, -2.7],
|
||||||
|
['M7', 2.12, 4.65, 2680, -4.99, -2.98], ['M7.5', 2.14, 4.72, 2630, -5.32, -3.15], ['M8', 2.15, 4.86, 2570, -5.65, -3.11],
|
||||||
|
['M8.5', 2.16, 5.1, 2420, -5.78, -3.09]
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rows each colour is tabulated for, filtered once. Filtered on every call, the search index
|
||||||
|
* and the star field's tints, which read the table for each of the 455 571 stars, spent 140-230
|
||||||
|
* ms of the main thread on it at boot, and the search index again on each opening of its tab.
|
||||||
|
*/
|
||||||
|
const ROWS_WITH_COLOUR = { 1: DWARF_SEQUENCE.filter((row) => row[1] !== null), 2: DWARF_SEQUENCE.filter((row) => row[2] !== null) } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The spectral type of the dwarf whose colour is nearest, for the stars no catalogue classified —
|
||||||
|
* every Gaia star, 83 % of the map. An estimate, and the caller must say so: it assumes a dwarf,
|
||||||
|
* so a giant is given a later type than its own — Pollux, a K0 giant at B−V 0.99, reads as K3 —
|
||||||
|
* and it ignores reddening, which makes a star behind dust look later still. `null` for a
|
||||||
|
* colour outside the table, rather than the nearest end of it.
|
||||||
|
*/
|
||||||
|
export function spectralTypeFromColor(colorIndex: number | null, system: 'B-V' | 'BP-RP' = 'B-V'): string | null {
|
||||||
|
const column = system === 'B-V' ? 1 : 2;
|
||||||
|
const rows = ROWS_WITH_COLOUR[column];
|
||||||
|
if (colorIndex === null || !(colorIndex >= rows[0][column]! && colorIndex <= rows[rows.length - 1][column]!)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let nearest = rows[0];
|
||||||
|
for (const row of rows) {
|
||||||
|
if (Math.abs(row[column]! - colorIndex) < Math.abs(nearest[column]! - colorIndex)) {
|
||||||
|
nearest = row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearest[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A star's classification as a row or an option lists it: the catalogue's type, or the dwarf type
|
||||||
|
* its colour matches, marked `~` as an estimate; empty with neither, rather than the ETL's literal
|
||||||
|
* "Unknown", which 383 695 stars carry and search rows and route options used to print.
|
||||||
|
*/
|
||||||
|
export function spectralClassification(star: { spectralType: string; colorIndex: number | null; colorSystem?: 'B-V' | 'BP-RP' }): string {
|
||||||
|
if (star.spectralType && star.spectralType !== 'Unknown') {
|
||||||
|
return star.spectralType;
|
||||||
|
}
|
||||||
|
const estimate = spectralTypeFromColor(star.colorIndex, star.colorSystem);
|
||||||
|
return estimate ? `~${estimate}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the dwarf sequence says of a star of a given colour. */
|
||||||
|
export interface DwarfSequencePoint {
|
||||||
|
/** B−V, the colour in the other system's terms where it was read off BP−RP; `null` among the O rows. */
|
||||||
|
bMinusV: number | null;
|
||||||
|
temperatureK: number;
|
||||||
|
/** Bolometric correction to V: what V leaves out of the star's total output, in magnitudes. */
|
||||||
|
bolometricCorrectionV: number;
|
||||||
|
/** Gaia G − Johnson V; `null` bluer than B1.5, where it is not tabulated. */
|
||||||
|
gMinusV: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dwarf sequence read at a colour, interpolated between the two types it falls between — the
|
||||||
|
* same table the spectral estimate reads, so a star's temperature and its estimated type agree.
|
||||||
|
* Linear rather than nearest, because the red end is steep: B−V runs 1.495 to 1.53 from M1.5 to
|
||||||
|
* M3, over which the temperature drops 190 K and the correction 0.4 magnitudes. `null` outside
|
||||||
|
* the table, as for the estimate — or, with `clampToTable`, the row at the end the colour is past,
|
||||||
|
* except bluer than BP−RP's end, where it is read off {@link WHITE_DWARF_BP_RP}.
|
||||||
|
*/
|
||||||
|
export function dwarfSequenceAtColor(colorIndex: number | null, system: 'B-V' | 'BP-RP' = 'B-V', clampToTable = false): DwarfSequencePoint | null {
|
||||||
|
const column = system === 'B-V' ? 1 : 2;
|
||||||
|
const rows = ROWS_WITH_COLOUR[column];
|
||||||
|
const [bluest, reddest] = [rows[0][column]!, rows[rows.length - 1][column]!];
|
||||||
|
if (colorIndex === null || !Number.isFinite(colorIndex) || (!clampToTable && !(colorIndex >= bluest && colorIndex <= reddest))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (system === 'BP-RP' && colorIndex < bluest) {
|
||||||
|
const next = Math.max(1, WHITE_DWARF_BP_RP.findIndex(([colour]) => colour >= colorIndex));
|
||||||
|
const [[blueColour, blueK], [redColour, redK]] = [WHITE_DWARF_BP_RP[next - 1], WHITE_DWARF_BP_RP[next]];
|
||||||
|
return dwarfSequenceAtTemperature(blueK + (redK - blueK) * Math.max((colorIndex - blueColour) / (redColour - blueColour), 0));
|
||||||
|
}
|
||||||
|
return sequenceWhere(rows, (row) => row[column]!, Math.min(Math.max(colorIndex, bluest), reddest));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective temperature by BP−RP past the blue end of the table, which is B9's −0.12: the median
|
||||||
|
* pure-hydrogen temperature Gentile Fusillo et al. (2021, MNRAS 508, 3877) fit, in bins of ±0.025,
|
||||||
|
* to the 104 of the map's stars there that their white dwarf catalogue has, all white dwarfs; with
|
||||||
|
* the table's end, 10 700 K, and their bluest bin held past it. Every one of them was drawn at B9's
|
||||||
|
* 10 700 K where they measure 14 266 to 39 304, and at a median 1.53 times the radius their mass
|
||||||
|
* and gravity give.
|
||||||
|
*/
|
||||||
|
const WHITE_DWARF_BP_RP: readonly (readonly [number, number])[] = [
|
||||||
|
[-0.4, 28585], [-0.35, 24521], [-0.3, 22090], [-0.25, 19012], [-0.2, 17079], [-0.15, 15369], [-0.12, 10700]
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dwarf sequence at a spectral type, between the two rows it falls between, O3 to M8.5; the end
|
||||||
|
* row past either end. `null` for a type with no class the parser reads.
|
||||||
|
*/
|
||||||
|
export function dwarfSequenceAtType(spectralType: string | null | undefined): DwarfSequencePoint | null {
|
||||||
|
const parsed = parseSpectralClass(spectralType);
|
||||||
|
return parsed && sequenceWhere(DWARF_SEQUENCE, (row) => TYPE_INDEX.get(row)!, typeIndex(parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A type as a number rising down the table: ten to a class, O0 at 0. */
|
||||||
|
function typeIndex({ spectralClass, subclass }: { spectralClass: SpectralClass; subclass: number }): number {
|
||||||
|
return SPECTRAL_CLASSES.indexOf(spectralClass) * 10 + subclass;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_INDEX = new Map(DWARF_SEQUENCE.map((row) => [row, typeIndex(parseSpectralClass(row[0])!)]));
|
||||||
|
|
||||||
|
/** The dwarf sequence at an effective temperature, between the two types it falls between; the end row past either end. */
|
||||||
|
export function dwarfSequenceAtTemperature(temperatureK: number): DwarfSequencePoint {
|
||||||
|
return sequenceWhere(ROWS_WITH_COLOUR[1], (row) => -row[3], -temperatureK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sequence where `key`, rising down `rows`, reaches `value`: linear between the two rows either side, the end row past either end. */
|
||||||
|
function sequenceWhere(rows: readonly SequenceRow[], key: (row: SequenceRow) => number, value: number): DwarfSequencePoint {
|
||||||
|
// Bisected, not scanned: the star field reads it for each of the 455 571 stars, and a scan of the
|
||||||
|
// rows took 200 ms of that.
|
||||||
|
let low = 1;
|
||||||
|
let high = rows.length - 1;
|
||||||
|
while (low < high) {
|
||||||
|
const middle = (low + high) >> 1;
|
||||||
|
if (key(rows[middle]) >= value) {
|
||||||
|
high = middle;
|
||||||
|
} else {
|
||||||
|
low = middle + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const first = rows[low - 1];
|
||||||
|
const second = rows[low];
|
||||||
|
const t = Math.min(Math.max((value - key(first)) / (key(second) - key(first)), 0), 1);
|
||||||
|
const lerp = (from: number, to: number): number => from + (to - from) * t;
|
||||||
|
return {
|
||||||
|
bMinusV: first[1] === null || second[1] === null ? null : lerp(first[1], second[1]),
|
||||||
|
temperatureK: lerp(first[3], second[3]),
|
||||||
|
bolometricCorrectionV: lerp(first[4], second[4]),
|
||||||
|
gMinusV: first[5] === null || second[5] === null ? null : lerp(first[5], second[5])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { raDegDecDistanceToXyz } from './coordinates';
|
import { raDegDecDistanceToXyz } from './coordinates';
|
||||||
import { StarRecord } from '../models/star.model';
|
import { StarRecord } from '../models/star.model';
|
||||||
import { directionCosine, isSameStar, MERGE_ANGULAR_TOLERANCE_DEG, mergeStarCatalogues, placementDistancePc } from './star-merge';
|
import { directionCosine, foldByIdentity, hipparcosDistancePc, HYG_UNKNOWN_DISTANCE_PC, isSameStar, MERGE_ANGULAR_TOLERANCE_DEG, mergeStarCatalogues, NAKED_EYE_MAGNITUDE, placementDistancePc } from './star-merge';
|
||||||
|
|
||||||
/** A star at a given sky position and distance, which is how catalogues actually report them. */
|
/** A star at a given sky position and distance, which is how catalogues actually report them. */
|
||||||
function at(id: number, raDeg: number, decDeg: number, distancePc: number, overrides: Partial<StarRecord> = {}): StarRecord {
|
function at(id: number, raDeg: number, decDeg: number, distancePc: number, overrides: Partial<StarRecord> = {}): StarRecord {
|
||||||
@@ -79,6 +79,40 @@ describe('isSameStar', () => {
|
|||||||
expect(isSameStar(companion, at(43109, 131.69 + arcsecOfRa(2.7, 6.42), 6.42, 40, { name: 'Ashlesha', magnitude: 3.38 }))).toBe(false);
|
expect(isSameStar(companion, at(43109, 131.69 + arcsecOfRa(2.7, 6.42), 6.42, 40, { name: 'Ashlesha', magnitude: 3.38 }))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GJ 1035 and GJ 3052 as HYG has them from Gliese, against their Gaia entries: 21″ away at
|
||||||
|
// about the same distance, and 6.6″ away at half Gaia's distance. Their motions agree to 2 and 3 %.
|
||||||
|
it('matches a Gliese entry to the Gaia entry moving with it, a minute of arc away or at another distance', () => {
|
||||||
|
const gj1035 = at(1, 19.9245, 84.1612, 14.4, { magnitude: 13.1, source: 'gaia', pmRaMasYr: -981.9, pmDecMasYr: 475.6 });
|
||||||
|
const gliese1035 = at(118058, 19.9245 + arcsecOfRa(21.2, 84.1612), 84.1612, 13.7, { name: 'GJ 1035', magnitude: 14.77, pmRaMasYr: -978.0, pmDecMasYr: 458.1 });
|
||||||
|
expect(isSameStar(gj1035, gliese1035)).toBe(true);
|
||||||
|
expect(isSameStar(gj1035, { ...gliese1035, pmRaMasYr: undefined, pmDecMasYr: undefined })).toBe(false);
|
||||||
|
|
||||||
|
const gj3052 = at(2, 11.0897, 9.1262, 25.0, { magnitude: 12.6, source: 'gaia', pmRaMasYr: 813.1, pmDecMasYr: -2.6 });
|
||||||
|
const gliese3052 = at(118014, 11.0897 + arcsecOfRa(6.6, 9.1262), 9.1262, 12.3, { name: 'GJ 3052', magnitude: 13.8, pmRaMasYr: 799.7, pmDecMasYr: -20.9 });
|
||||||
|
expect(isSameStar(gj3052, gliese3052)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches LHS 288's Gliese entry to its Gaia entry, a minute and a half away", () => {
|
||||||
|
const lhs288 = at(1000388933, 161.08846863703002, -61.20979834516761, 4.8316, { magnitude: 11.859, source: 'gaia', pmRaMasYr: -346.21, pmDecMasYr: 1611.1 });
|
||||||
|
const gliese3618 = at(118704, 161.13112906780827, -61.1935811389294, 4.4883, { name: 'GJ 3618', magnitude: 13.92, pmRaMasYr: -340.24, pmDecMasYr: 1614.54 });
|
||||||
|
expect(Math.acos(directionCosine(lhs288, gliese3618)) * (180 / Math.PI) * 3600).toBeCloseTo(94, 0);
|
||||||
|
expect(isSameStar(lhs288, gliese3618)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match two entries moving differently past fifteen arcseconds, nor co-moving ones past 160″', () => {
|
||||||
|
const kept = at(1, 120, 30, 10, { magnitude: 12, source: 'gaia', pmRaMasYr: 1000, pmDecMasYr: 0 });
|
||||||
|
expect(isSameStar(kept, at(2, 120 + arcsecOfRa(20, 30), 30, 10, { magnitude: 13, pmRaMasYr: 750, pmDecMasYr: 0 }))).toBe(false);
|
||||||
|
expect(isSameStar(kept, at(2, 120 + arcsecOfRa(20, 30), 30, 10, { magnitude: 13, pmRaMasYr: 850, pmDecMasYr: 0 }))).toBe(true);
|
||||||
|
expect(isSameStar(kept, at(2, 120 + arcsecOfRa(155, 30), 30, 10, { magnitude: 13, pmRaMasYr: 1000, pmDecMasYr: 0 }))).toBe(true);
|
||||||
|
expect(isSameStar(kept, at(2, 120 + arcsecOfRa(165, 30), 30, 10, { magnitude: 13, pmRaMasYr: 1000, pmDecMasYr: 0 }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a co-moving primary out of its companion's entry", () => {
|
||||||
|
// A binary shares its motion, so only the brightness tells GJ 9160 from its companion 13.5″ away.
|
||||||
|
const companion = at(1, 69.54, -14.3, 24.4, { magnitude: 15.1, source: 'gaia', pmRaMasYr: -78.5, pmDecMasYr: -150.8 });
|
||||||
|
expect(isSameStar(companion, at(2, 69.54 + arcsecOfRa(13.5, -14.3), -14.3, 24.4, { magnitude: 7.3, pmRaMasYr: -78.5, pmDecMasYr: -150.8 }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('matches on direction rather than on 3D proximity', () => {
|
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
|
// 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.
|
// star; a 3D-proximity test would have to be so loose it swallowed real neighbours.
|
||||||
@@ -131,6 +165,52 @@ describe('mergeStarCatalogues', () => {
|
|||||||
expect(summary.duplicates).toBe(1);
|
expect(summary.duplicates).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the distance's error with the distance, and the photometry with the description", () => {
|
||||||
|
// Proxima's parallax is 768.07 ± 0.05 mas in Gaia and 768.13 ± 1.04 in Hipparcos: the star is
|
||||||
|
// drawn at Gaia's, so the error it is drawn with is Gaia's, while V 11.01 and B−V 1.81 stay HYG's.
|
||||||
|
const hyg = at(70666, 217.4289, -62.6795, 1.2959, {
|
||||||
|
name: 'Proxima Centauri',
|
||||||
|
magnitude: 11.01,
|
||||||
|
magnitudeBand: 'V',
|
||||||
|
colorIndex: 1.807,
|
||||||
|
colorSystem: 'B-V',
|
||||||
|
distanceError: 0.00135,
|
||||||
|
distanceFromGaia: false
|
||||||
|
});
|
||||||
|
const gaia = at(1000064182, 217.4289, -62.6795, 1.302, {
|
||||||
|
name: 'Gaia DR3 5853498713190525696',
|
||||||
|
magnitude: 8.985,
|
||||||
|
magnitudeBand: 'G',
|
||||||
|
colorIndex: 3.805,
|
||||||
|
colorSystem: 'BP-RP',
|
||||||
|
distanceError: 0.000065,
|
||||||
|
distanceFromGaia: true,
|
||||||
|
source: 'gaia'
|
||||||
|
});
|
||||||
|
const [merged] = mergeStarCatalogues([{ ...HIPPARCOS, stars: [hyg] }, { ...GAIA, stars: [gaia] }]).stars;
|
||||||
|
|
||||||
|
expect(merged).toMatchObject({ magnitude: 11.01, magnitudeBand: 'V', colorIndex: 1.807, colorSystem: 'B-V', distanceError: 0.000065, distanceFromGaia: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Gaia's colour where the description has none", () => {
|
||||||
|
// HD 45951: HYG gives V 6.20 and K2III but no B−V; Gaia DR3 3369454521490604416 has BP−RP 1.248.
|
||||||
|
const hyg = at(119622, 97.79164, 16.93863, 112, { name: 'HD 45951', magnitude: 6.2, magnitudeBand: 'V', spectralType: 'K2III', colorIndex: null });
|
||||||
|
const gaia = at(1000004369, 97.79164, 16.93863, 112, { name: 'Gaia DR3 3369454521490604416', magnitude: 5.898, magnitudeBand: 'G', colorIndex: 1.248, colorSystem: 'BP-RP', source: 'gaia' });
|
||||||
|
const [merged] = mergeStarCatalogues([{ ...HIPPARCOS, stars: [hyg] }, { ...GAIA, stars: [gaia] }]).stars;
|
||||||
|
|
||||||
|
expect(merged).toMatchObject({ name: 'HD 45951', magnitude: 6.2, magnitudeBand: 'V', colorIndex: 1.248, colorSystem: 'BP-RP' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes a Hipparcos distance more precise than the Gaia entry it folds into, along Gaia's direction", () => {
|
||||||
|
// Schedar: 71.0 pc ±3.5 % in Gaia, which saturates on it, and 70.0 pc ±1.0 % in Hipparcos.
|
||||||
|
const hyg = at(3179, 10.1268, 56.5373, 70.0, { name: 'Schedar', magnitude: 2.24, distanceError: 0.0105, distanceFromGaia: false });
|
||||||
|
const gaia = at(1000000100, 10.1268 + arcsecOfRa(0.2, 56.5373), 56.5373, 71.0, { name: 'Gaia DR3 425040000962559616', magnitude: 1.94, distanceError: 0.035, distanceFromGaia: true, source: 'gaia' });
|
||||||
|
const [merged] = mergeStarCatalogues([{ ...HIPPARCOS, stars: [hyg] }, { ...GAIA, stars: [gaia] }]).stars;
|
||||||
|
expect(Math.hypot(merged.x, merged.y, merged.z)).toBeCloseTo(70.0, 9);
|
||||||
|
expect(directionCosine(merged, gaia)).toBeCloseTo(1, 12);
|
||||||
|
expect(merged).toMatchObject({ name: 'Schedar', distanceError: 0.0105, distanceFromGaia: false, source: 'gaia' });
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps two entries of one source apart, however close they are', () => {
|
it('keeps two entries of one source apart, however close they are', () => {
|
||||||
// Gaia resolves doubles Hipparcos saw as one star: two source ids 0.8″ apart are two stars,
|
// Gaia resolves doubles Hipparcos saw as one star: two source ids 0.8″ apart are two stars,
|
||||||
// and only *another* catalogue can claim to have already listed either of them.
|
// and only *another* catalogue can claim to have already listed either of them.
|
||||||
@@ -238,34 +318,136 @@ describe('mergeStarCatalogues', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('foldByIdentity', () => {
|
||||||
|
// GJ 4285 as HYG has it, at its Gliese photometric distance and V, with no colour, and the Gaia
|
||||||
|
// source SIMBAD names as the same star, L 119-44, 50.6″ away: G 13.05 and BP−RP 2.74, which give V 14.4.
|
||||||
|
const gliese = at(119513, 339.5, -65.84, 6.8, { name: 'GJ 4285', source: 'hyg', magnitude: 11.45, magnitudeBand: 'V', spectralType: 'm', colorIndex: null });
|
||||||
|
const gaia = at(1050005263, 339.5 + arcsecOfRa(50.6, -65.84), -65.84, 28.25, {
|
||||||
|
name: 'Gaia DR3 6392188629658709888',
|
||||||
|
gaiaDesignation: 'Gaia DR3 6392188629658709888',
|
||||||
|
source: 'gaia',
|
||||||
|
magnitude: 13.05,
|
||||||
|
magnitudeBand: 'G',
|
||||||
|
colorIndex: 2.74,
|
||||||
|
colorSystem: 'BP-RP',
|
||||||
|
distanceError: 0.0004,
|
||||||
|
distanceFromGaia: true
|
||||||
|
});
|
||||||
|
const identities = new Map([[gliese.id, gaia.gaiaDesignation!]]);
|
||||||
|
|
||||||
|
it("folds a Gliese entry into the Gaia entry SIMBAD names it as, at Gaia's position and distance and in Gaia's photometry", () => {
|
||||||
|
expect(isSameStar(gaia, gliese)).toBe(false);
|
||||||
|
const { stars, folded } = foldByIdentity([gliese, gaia], identities);
|
||||||
|
expect(folded).toBe(1);
|
||||||
|
expect(stars).toHaveLength(1);
|
||||||
|
expect(stars[0]).toMatchObject({ id: gliese.id, name: 'GJ 4285', spectralType: 'm', source: 'gaia', distanceError: 0.0004, distanceFromGaia: true });
|
||||||
|
expect(stars[0]).toMatchObject({ magnitude: 13.05, magnitudeBand: 'G', colorIndex: 2.74, colorSystem: 'BP-RP' });
|
||||||
|
expect(Math.hypot(stars[0].x, stars[0].y, stars[0].z)).toBeCloseTo(28.25, 9);
|
||||||
|
expect(directionCosine(stars[0], gaia)).toBeCloseTo(1, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds one into a Gaia entry a HYG star of the same brightness already describes, and keeps that star', () => {
|
||||||
|
// HYG lists GJ 251 twice: HD 265866, a Hipparcos row merged with its Gaia source, and Gl 251,
|
||||||
|
// 10.9″ away at a Gliese distance of 5.76 pc, which SIMBAD names as the same source.
|
||||||
|
const hd265866 = at(33139, 103.7, 33.27, 5.58, { name: 'HD 265866', source: 'hyg', magnitude: 9.89, magnitudeBand: 'V', spectralType: 'M3', colorIndex: 1.6, colorSystem: 'B-V', distanceError: 0.004 });
|
||||||
|
const source = at(1000033139, 103.7, 33.27, 5.585, { name: 'Gaia DR3 939072613334579328', gaiaDesignation: 'Gaia DR3 939072613334579328', source: 'gaia', magnitude: 8.9, magnitudeBand: 'G', distanceError: 0.0002 });
|
||||||
|
const gl251 = at(118447, 103.7 + arcsecOfRa(10.9, 33.27), 33.27, 5.76, { name: 'Gl 251', source: 'hyg', magnitude: 10.01, magnitudeBand: 'V', spectralType: 'M4', colorIndex: null });
|
||||||
|
const { stars: merged } = mergeStarCatalogues([{ ...HIPPARCOS, stars: [hd265866] }, { ...GAIA, stars: [source] }]);
|
||||||
|
const { stars, folded } = foldByIdentity([...merged, gl251], new Map([[gl251.id, source.gaiaDesignation!]]));
|
||||||
|
expect(folded).toBe(1);
|
||||||
|
expect(stars).toHaveLength(1);
|
||||||
|
expect(stars[0]).toMatchObject({ id: 33139, name: 'HD 265866', magnitude: 9.89, magnitudeBand: 'V', colorIndex: 1.6, source: 'gaia' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes the entry by the Gliese row where SIMBAD names the HYG star already there as another source', () => {
|
||||||
|
// HYG hangs "Gl 905.2A", M5, on HIP 117059; SIMBAD has HIP 117059 as LAWD 93, Gl 905.2B, a DA
|
||||||
|
// white dwarf at 53.76 mas, and Gl 905.2A as G 130-6, a source 3′ away with no parallax.
|
||||||
|
const hip117059 = at(116690, 355.96134, 32.54631, 17.13, { name: 'Gl 905.2A', source: 'hyg', magnitude: 13.11, magnitudeBand: 'V', spectralType: 'M5', colorIndex: 1.55, colorSystem: 'B-V', distanceError: 0.1 });
|
||||||
|
const lawd93 = at(1000116690, 355.96134, 32.54631, 18.6, { name: 'Gaia DR3 2871730307948650368', gaiaDesignation: 'Gaia DR3 2871730307948650368', source: 'gaia', magnitude: 12.97, magnitudeBand: 'G', colorIndex: -0.04, colorSystem: 'BP-RP', distanceError: 0.0006 });
|
||||||
|
const gl905b = at(119589, 355.96134 + arcsecOfRa(8, 32.54631), 32.54631, 16.64, { name: 'Gl 905.2B', source: 'hyg', magnitude: 12.9, magnitudeBand: 'V', spectralType: 'DA4', colorIndex: 0.15, colorSystem: 'B-V' });
|
||||||
|
const { stars: merged } = mergeStarCatalogues([{ ...HIPPARCOS, stars: [hip117059] }, { ...GAIA, stars: [lawd93] }]);
|
||||||
|
const identities = new Map([
|
||||||
|
[hip117059.id, 'Gaia DR3 2871730758921709952'],
|
||||||
|
[gl905b.id, lawd93.gaiaDesignation!]
|
||||||
|
]);
|
||||||
|
const { stars, folded } = foldByIdentity([...merged, gl905b], identities);
|
||||||
|
expect(folded).toBe(1);
|
||||||
|
expect(stars).toHaveLength(1);
|
||||||
|
expect(stars[0]).toMatchObject({ id: 119589, name: 'Gl 905.2B', spectralType: 'DA4', magnitude: 12.9, colorIndex: 0.15, source: 'gaia' });
|
||||||
|
expect(Math.hypot(stars[0].x, stars[0].y, stars[0].z)).toBeCloseTo(18.6, 9);
|
||||||
|
// Nor the M5's B−V where the row has no colour of its own (Gl 225.2C, beside HD 40887's).
|
||||||
|
expect(foldByIdentity([...merged, { ...gl905b, colorIndex: null }], identities).stars[0].colorIndex).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a star with a Hipparcos error alone, and one of another brightness than the HYG star already there', () => {
|
||||||
|
expect(foldByIdentity([{ ...gliese, distanceError: 0.05 }, gaia], identities).folded).toBe(0);
|
||||||
|
// A companion SIMBAD gives its primary's source: two magnitudes apart.
|
||||||
|
expect(foldByIdentity([gliese, { ...gaia, name: 'L 119-44', magnitude: 13.45 }], identities).folded).toBe(0);
|
||||||
|
expect(foldByIdentity([gliese, { ...gaia, name: 'L 119-44', magnitude: 11.85 }], identities).folded).toBe(1);
|
||||||
|
expect(foldByIdentity([gliese, gaia], new Map()).folded).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hipparcosDistancePc', () => {
|
||||||
|
it("takes HYG's distance where it gives one", () => {
|
||||||
|
expect(hipparcosDistancePc(606.06, { parallaxMas: 1.65, relativeError: 0.45 / 1.65 })).toBe(606.06);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places a star HYG gives no distance for by its parallax, if the parallax is 2.5 times its error", () => {
|
||||||
|
// HD 74180 at 0.67 ± 0.16 mas and Mu Cep at 0.55 ± 0.20; a parallax at 1.5 times its error stays out.
|
||||||
|
expect(hipparcosDistancePc(HYG_UNKNOWN_DISTANCE_PC, { parallaxMas: 0.67, relativeError: 0.16 / 0.67 })).toBeCloseTo(1492.5, 1);
|
||||||
|
expect(hipparcosDistancePc(HYG_UNKNOWN_DISTANCE_PC, { parallaxMas: 0.55, relativeError: 0.2 / 0.55 })).toBeCloseTo(1818.2, 1);
|
||||||
|
expect(hipparcosDistancePc(HYG_UNKNOWN_DISTANCE_PC, { parallaxMas: 0.3, relativeError: 0.2 / 0.3 })).toBeUndefined();
|
||||||
|
expect(hipparcosDistancePc(HYG_UNKNOWN_DISTANCE_PC)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('placementDistancePc', () => {
|
describe('placementDistancePc', () => {
|
||||||
it("draws a star both surveys measured at Gaia's distance", () => {
|
it("draws a star both surveys measured at Gaia's distance", () => {
|
||||||
expect(placementDistancePc(120, 118.4, 250)).toBe(118.4);
|
expect(placementDistancePc(120, 118.4, 8, 250)).toBe(118.4);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The case the old cut got wrong: Hipparcos inside, Gaia outside. Kept, at the distance Gaia
|
// The case the old cut got wrong: Hipparcos inside, Gaia outside. Kept, at the distance Gaia
|
||||||
// gives, rather than at one a third short or dropped for having been misplaced.
|
// gives, rather than at one a third short or dropped for having been misplaced.
|
||||||
it('keeps a star Hipparcos put inside the cutoff, where Gaia puts it, even past the cutoff', () => {
|
it('keeps a star Hipparcos put inside the cutoff, where Gaia puts it, even past the cutoff', () => {
|
||||||
expect(placementDistancePc(200, 306, 250)).toBe(306);
|
expect(placementDistancePc(200, 306, 8, 250)).toBe(306);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The mirror image: Hipparcos outside, Gaia inside. The Gaia download already holds the star,
|
// The mirror image: Hipparcos outside, Gaia inside. The Gaia download already holds the star,
|
||||||
// and keeping the HYG row is what lets the merge give that entry its name.
|
// and keeping the HYG row is what lets the merge give that entry its name.
|
||||||
it('keeps a star only Gaia puts inside the cutoff', () => {
|
it('keeps a star only Gaia puts inside the cutoff', () => {
|
||||||
expect(placementDistancePc(262, 241, 250)).toBe(241);
|
expect(placementDistancePc(262, 241, 8, 250)).toBe(241);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a star Gaia measured and Hipparcos gave no distance for', () => {
|
it('keeps a star Gaia measured and Hipparcos gave no distance for', () => {
|
||||||
expect(placementDistancePc(undefined, 180, 250)).toBe(180);
|
expect(placementDistancePc(undefined, 180, 8, 250)).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the distance with the smaller error, Hipparcos's where Gaia saturated", () => {
|
||||||
|
// Eta Leo: 556.6 pc ±17 % in Gaia, 389.1 pc ±6.2 % in Hipparcos. Sirius the other way round.
|
||||||
|
expect(placementDistancePc(389.1, 556.6, 3.5, 250, 0.062, 0.17)).toBe(389.1);
|
||||||
|
expect(placementDistancePc(2.64, 2.67, -1.44, 250, 0.004, 0.002)).toBe(2.67);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to Hipparcos where Gaia has no usable distance', () => {
|
it('falls back to Hipparcos where Gaia has no usable distance', () => {
|
||||||
expect(placementDistancePc(90, undefined, 250)).toBe(90);
|
expect(placementDistancePc(90, undefined, 8, 250)).toBe(90);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops a star both surveys put outside, or neither measured', () => {
|
it('drops a star both surveys put outside, or neither measured', () => {
|
||||||
expect(placementDistancePc(300, 410, 250)).toBeNull();
|
expect(placementDistancePc(300, 410, 8, 250)).toBeNull();
|
||||||
expect(placementDistancePc(300, undefined, 250)).toBeNull();
|
expect(placementDistancePc(300, undefined, 8, 250)).toBeNull();
|
||||||
expect(placementDistancePc(undefined, undefined, 250)).toBeNull();
|
expect(placementDistancePc(undefined, undefined, 8, 250)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rigel: Hipparcos 265 pc, and no Gaia distance, since Gaia saturates on it. The cutoff bounds a
|
||||||
|
// download, not what the sky shows, and a map without the middle of Orion's belt is not the sky.
|
||||||
|
it('keeps a star the naked eye sees at any distance, at the better one', () => {
|
||||||
|
expect(placementDistancePc(265, undefined, 0.18, 250)).toBe(265);
|
||||||
|
expect(placementDistancePc(433, 802, NAKED_EYE_MAGNITUDE, 250)).toBe(802);
|
||||||
|
expect(placementDistancePc(433, 802, NAKED_EYE_MAGNITUDE + 0.01, 250)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still drops a naked-eye star no survey gives a distance for', () => {
|
||||||
|
expect(placementDistancePc(undefined, undefined, 3.3, 250)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,6 +47,25 @@ export const MERGE_ANGULAR_TOLERANCE_DEG = 15 / 3600;
|
|||||||
*/
|
*/
|
||||||
export const MERGE_CERTAIN_ANGULAR_TOLERANCE_DEG = 3 / 3600;
|
export const MERGE_CERTAIN_ANGULAR_TOLERANCE_DEG = 3 / 3600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Angular separation, in degrees, within which two entries that cross the sky together are one
|
||||||
|
* star, and how closely their proper motions must agree (as a fraction of the kept entry's) to
|
||||||
|
* say so. Past fifteen arcseconds, and past a distance conflict, a Gliese-only entry is still
|
||||||
|
* often the same star: its position is off by up to a minute of arc and its distance is
|
||||||
|
* photometric — GJ 1035 sits 21″ from its Gaia entry, GJ 3052 at half Gaia's distance. What
|
||||||
|
* gives them away is their motion, which Gliese measured well: a few per cent from Gaia's.
|
||||||
|
*
|
||||||
|
* Once the nearby faint Gaia stars joined, 253 of the 602 Gliese-only stars left without a
|
||||||
|
* counterpart had a Gaia entry within a minute of arc moving within a fifth of their own motion;
|
||||||
|
* shifted a quarter of a degree, none did. A minute was not enough, though: 39 Gliese stars within
|
||||||
|
* 25 pc still had a bare Gaia entry moving with them 60 to 150″ away, 36 of which SIMBAD names as
|
||||||
|
* the same star — GJ 3618, LHS 288, drawn at 4.49 pc and again 94″ away at 4.83 — while shifted a
|
||||||
|
* quarter of a degree, none did. So 160″. Brightness still has its say, so a co-moving companion
|
||||||
|
* is not folded into its primary, and `fetchStars` gives HYG's motions only to those rows.
|
||||||
|
*/
|
||||||
|
export const MERGE_COMOVING_ANGULAR_TOLERANCE_DEG = 160 / 3600;
|
||||||
|
export const MERGE_PROPER_MOTION_TOLERANCE = 0.2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How much fainter, and how much brighter, an entry may be than the one it is folded into and
|
* How much fainter, and how much brighter, an entry may be than the one it is folded into and
|
||||||
* still be the same star. Bands differ, and not symmetrically: a red dwarf is three magnitudes
|
* still be the same star. Bands differ, and not symmetrically: a red dwarf is three magnitudes
|
||||||
@@ -67,25 +86,62 @@ export const MERGE_BRIGHTER_TOLERANCE = 1;
|
|||||||
*/
|
*/
|
||||||
export const MERGE_DISTANCE_RATIO_TOLERANCE = 0.5;
|
export const MERGE_DISTANCE_RATIO_TOLERANCE = 0.5;
|
||||||
|
|
||||||
|
/** HYG's distance for a star whose parallax it does not give one for. */
|
||||||
|
export const HYG_UNKNOWN_DISTANCE_PC = 100000;
|
||||||
|
|
||||||
|
/** How many times its error a parallax HYG leaves out must be to place a star by: a 40 % error. */
|
||||||
|
const MIN_HIPPARCOS_PARALLAX_OVER_ERROR = 2.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A HYG star's Hipparcos distance: HYG's own, which is the inverse of van Leeuwen's 2007 parallax,
|
||||||
|
* or where HYG gives its placeholder instead, that inverse if the parallax is at least 2.5 times its
|
||||||
|
* error. HYG gives no distance under 1 mas whatever the error, while keeping less certain parallaxes
|
||||||
|
* above it: 41 naked-eye stars were left off the map as having no distance, HD 74180 at
|
||||||
|
* 0.67 ± 0.16 mas and Mu Cep at 0.55 ± 0.20 among them, while Alnilam at 1.65 ± 0.45 was drawn.
|
||||||
|
*/
|
||||||
|
export function hipparcosDistancePc(hygPc: number, parallax?: { parallaxMas: number; relativeError: number }): number | undefined {
|
||||||
|
if (Number.isFinite(hygPc) && hygPc > 0 && hygPc < HYG_UNKNOWN_DISTANCE_PC) {
|
||||||
|
return hygPc;
|
||||||
|
}
|
||||||
|
return parallax && parallax.relativeError <= 1 / MIN_HIPPARCOS_PARALLAX_OVER_ERROR ? 1000 / parallax.parallaxMas : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The faintest star, in V, the naked eye sees under a dark sky: the traditional limit of 6.5. */
|
||||||
|
export const NAKED_EYE_MAGNITUDE = 6.5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where to draw a star Hipparcos and Gaia both measured, and whether the map keeps it at all.
|
* Where to draw a star Hipparcos and Gaia both measured, and whether the map keeps it at all.
|
||||||
*
|
*
|
||||||
* Gaia's distance wherever it has a usable one, since its parallaxes are fifty times more
|
* At whichever distance has the smaller relative error, given both; Gaia's without them, or
|
||||||
* precise; Hipparcos's otherwise. The two catalogues used to be cut at the same radius, each on
|
* Hipparcos's where Gaia has none. Gaia's parallaxes are some fifty times more precise, and its
|
||||||
|
* distance wins for all but 273 of the 88 781 HYG stars with both; those are bright stars Gaia
|
||||||
|
* saturates on, 257 of them naked-eye — Eta Leo is 556.6 pc ±17 % in Gaia and 389 pc ±6.2 % in
|
||||||
|
* Hipparcos, Schedar ±3.5 % against ±1.0 %. The two catalogues used to be cut at the same radius, each on
|
||||||
* its own distance, so a star Hipparcos put at 200 pc and Gaia at 300 was kept by one, never
|
* its own distance, so a star Hipparcos put at 200 pc and Gaia at 300 was kept by one, never
|
||||||
* downloaded from the other, and drawn at 200. That was 83% of the HYG stars left without a
|
* downloaded from the other, and drawn at 200. That was 83% of the HYG stars left without a
|
||||||
* Gaia counterpart, and at the median Hipparcos had them at two-thirds of Gaia's distance.
|
* Gaia counterpart, and at the median Hipparcos had them at two-thirds of Gaia's distance.
|
||||||
*
|
*
|
||||||
* Now a star either survey places inside `cutoffPc` is kept, and every kept star sits where the
|
* Now a star either survey places inside `cutoffPc` is kept, and every kept star sits where the
|
||||||
* better measurement puts it, inside the cutoff or not. `null` for a star neither survey places
|
* better measurement puts it, inside the cutoff or not. So is every star the naked eye sees, at
|
||||||
* inside, or that no survey gives a distance for.
|
* any distance: the cutoff took 1 543 of HYG's 8 920 stars of V 6.5 or brighter, Rigel, Deneb
|
||||||
|
* and Alnilam among them, while 11th-magnitude Gaia stars at the same distance were drawn. Those
|
||||||
|
* Gaia has no usable parallax for sit at their Hipparcos distance. `null` for a star kept by
|
||||||
|
* neither rule, or that no survey gives a distance for.
|
||||||
*/
|
*/
|
||||||
export function placementDistancePc(hipparcosPc: number | undefined, gaiaPc: number | undefined, cutoffPc: number): number | null {
|
export function placementDistancePc(
|
||||||
const best = gaiaPc ?? hipparcosPc;
|
hipparcosPc: number | undefined,
|
||||||
|
gaiaPc: number | undefined,
|
||||||
|
magnitude: number,
|
||||||
|
cutoffPc: number,
|
||||||
|
hipparcosError?: number,
|
||||||
|
gaiaError?: number
|
||||||
|
): number | null {
|
||||||
|
const hipparcosBetter = hipparcosPc !== undefined && hipparcosError !== undefined && gaiaError !== undefined && hipparcosError < gaiaError;
|
||||||
|
const best = hipparcosBetter ? hipparcosPc : (gaiaPc ?? hipparcosPc);
|
||||||
if (best === undefined) {
|
if (best === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const inside = best <= cutoffPc || (hipparcosPc !== undefined && hipparcosPc <= cutoffPc);
|
const inside = magnitude <= NAKED_EYE_MAGNITUDE || best <= cutoffPc || (hipparcosPc !== undefined && hipparcosPc <= cutoffPc);
|
||||||
return inside ? best : null;
|
return inside ? best : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,10 +209,19 @@ export function directionCosine(a: StarRecord, b: StarRecord): number {
|
|||||||
return Math.max(-1, Math.min(1, ax * bx + ay * by + az * bz));
|
return Math.max(-1, Math.min(1, ax * bx + ay * by + az * bz));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether both entries have a proper motion and `entry`'s is within tolerance of `kept`'s. */
|
||||||
|
function movesWith(kept: StarRecord, entry: StarRecord): boolean {
|
||||||
|
if (kept.pmRaMasYr === undefined || kept.pmDecMasYr === undefined || entry.pmRaMasYr === undefined || entry.pmDecMasYr === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const difference = Math.hypot(entry.pmRaMasYr - kept.pmRaMasYr, entry.pmDecMasYr - kept.pmDecMasYr);
|
||||||
|
return difference < MERGE_PROPER_MOTION_TOLERANCE * Math.hypot(kept.pmRaMasYr, kept.pmDecMasYr);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether `entry` describes the star already `kept`: the same direction, the brightness not in
|
* Whether `entry` describes the star already `kept`: the same direction, the brightness not in
|
||||||
* conflict and — unless the directions agree closely enough to settle it — the distance not in
|
* conflict and — unless the directions agree closely enough to settle it, or the two move
|
||||||
* conflict either.
|
* together — the distance not in conflict either.
|
||||||
*/
|
*/
|
||||||
export function isSameStar(kept: StarRecord, entry: StarRecord): boolean {
|
export function isSameStar(kept: StarRecord, entry: StarRecord): boolean {
|
||||||
const [near, far] = [distanceOf(kept), distanceOf(entry)].sort((p, q) => p - q);
|
const [near, far] = [distanceOf(kept), distanceOf(entry)].sort((p, q) => p - q);
|
||||||
@@ -169,7 +234,8 @@ export function isSameStar(kept: StarRecord, entry: StarRecord): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const separationDeg = Math.acos(directionCosine(kept, entry)) / DEG_TO_RAD;
|
const separationDeg = Math.acos(directionCosine(kept, entry)) / DEG_TO_RAD;
|
||||||
if (separationDeg > MERGE_ANGULAR_TOLERANCE_DEG) {
|
const comoving = movesWith(kept, entry);
|
||||||
|
if (separationDeg > (comoving ? MERGE_COMOVING_ANGULAR_TOLERANCE_DEG : MERGE_ANGULAR_TOLERANCE_DEG)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const fainterBy = entry.magnitude - kept.magnitude;
|
const fainterBy = entry.magnitude - kept.magnitude;
|
||||||
@@ -177,7 +243,7 @@ export function isSameStar(kept: StarRecord, entry: StarRecord): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (separationDeg <= MERGE_CERTAIN_ANGULAR_TOLERANCE_DEG) {
|
if (comoving || separationDeg <= MERGE_CERTAIN_ANGULAR_TOLERANCE_DEG) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,12 +256,106 @@ export function isSameStar(kept: StarRecord, entry: StarRecord): boolean {
|
|||||||
* a catalogue number. HYG's "Proxima Centauri", "M5Ve" and V magnitude over Gaia's
|
* a catalogue number. HYG's "Proxima Centauri", "M5Ve" and V magnitude over Gaia's
|
||||||
* "Gaia DR3 5853498713190525696", "Unknown" and G; keeping either row whole loses half of that,
|
* "Gaia DR3 5853498713190525696", "Unknown" and G; keeping either row whole loses half of that,
|
||||||
* and keeping Gaia's whole once cost the map 102 proper names and 32 000 spectral types. The id
|
* and keeping Gaia's whole once cost the map 102 proper names and 32 000 spectral types. The id
|
||||||
* travels with the description, so a star HYG knows keeps its HYG id from one refresh to the next;
|
* travels with the description, so a star HYG knows keeps its HYG id from one refresh to the next,
|
||||||
* `source` stays with the position, since that is what it records.
|
* and so does its photometry, V and B−V. `source` stays with the position, since that is what it
|
||||||
|
* records, and so does the distance's error: Gaia's, not the Hipparcos one of a distance dropped —
|
||||||
|
* unless the other entry's distance is the more precise, as the Hipparcos one of a bright star
|
||||||
|
* `placementDistancePc` keeps at it is, which then sets the distance along Gaia's direction.
|
||||||
|
* `described` overrides that choice where an identity settles it (see {@link foldByIdentity}).
|
||||||
|
*
|
||||||
|
* A colour the description lacks comes from the other entry, in its own system: HYG has none for
|
||||||
|
* HD 45951, HD 45291 and HD 124953, three naked-eye giants Gaia measures at BP−RP 1.25, 1.19 and
|
||||||
|
* 0.37, and each card showed no colour at all.
|
||||||
*/
|
*/
|
||||||
function combine(kept: StarRecord, other: StarRecord): StarRecord {
|
function combine(kept: StarRecord, other: StarRecord, described = isDesignation(kept) && !isDesignation(other) ? other : kept): StarRecord {
|
||||||
const described = isDesignation(kept) && !isDesignation(other) ? other : kept;
|
const otherBetter = other.distanceError !== undefined && kept.distanceError !== undefined && other.distanceError < kept.distanceError;
|
||||||
return { ...described, x: kept.x, y: kept.y, z: kept.z, source: kept.source };
|
const placed = otherBetter ? other : kept;
|
||||||
|
const scale = otherBetter ? distanceOf(other) / distanceOf(kept) : 1;
|
||||||
|
const gaiaDesignation = kept.gaiaDesignation ?? other.gaiaDesignation;
|
||||||
|
const coloured = described.colorIndex !== null ? described : described === kept ? other : kept;
|
||||||
|
return {
|
||||||
|
...described,
|
||||||
|
colorIndex: coloured.colorIndex,
|
||||||
|
colorSystem: coloured.colorSystem,
|
||||||
|
x: kept.x * scale,
|
||||||
|
y: kept.y * scale,
|
||||||
|
z: kept.z * scale,
|
||||||
|
source: kept.source,
|
||||||
|
distanceError: placed.distanceError,
|
||||||
|
distanceFromGaia: placed.distanceFromGaia,
|
||||||
|
...(gaiaDesignation === undefined ? {} : { gaiaDesignation })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far apart in V a Gliese-only entry and the HYG star already on its Gaia entry may be and still
|
||||||
|
* be one star. Of the 14 such folds, 11 agree within 0.12 (Gl 251 and HD 265866, 10.01 and 9.89);
|
||||||
|
* Gl 905.2B, Gl 225.2C and 69 Tau Oph differ by 0.21, 0.45 and 0.47, and on all three SIMBAD puts
|
||||||
|
* the HYG star already there on another source (see {@link foldByIdentity}). A companion SIMBAD gives
|
||||||
|
* its primary's source differs by magnitudes.
|
||||||
|
*/
|
||||||
|
export const IDENTITY_FOLD_MAGNITUDE_TOLERANCE = 0.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a Gliese-only entry folds into `target`, the Gaia entry of the source SIMBAD names it as:
|
||||||
|
* always where that entry is still bare, a Gaia designation with Gaia's G, which SIMBAD's identity
|
||||||
|
* settles whatever HYG's V says; where a HYG star already describes it, only if the two V agree.
|
||||||
|
*/
|
||||||
|
export function foldsInto(target: StarRecord, entry: StarRecord): boolean {
|
||||||
|
return isDesignation(target) || Math.abs(target.magnitude - entry.magnitude) <= IDENTITY_FOLD_MAGNITUDE_TOLERANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folds each Gliese-only entry — HYG's, with no Hipparcos astrometry and so no published error on
|
||||||
|
* its distance — into the Gaia entry of the source SIMBAD names it as, `gaiaDesignationById` by
|
||||||
|
* HYG id (see {@link foldsInto}). {@link isSameStar} cannot see these: 43 of them within 25 pc stayed
|
||||||
|
* beside their own bare Gaia entry, too far for their positions (GJ 3478, 16″), moving differently
|
||||||
|
* by HYG's motions (GJ 2097, 39 %), or brighter in HYG's V than Gaia's G by more than a primary may
|
||||||
|
* be (GJ 4285, 1.6 magnitudes). Two of those were stars that do not exist inside 10 pc: GJ 2097 at
|
||||||
|
* 6.41 pc and GJ 4285 at 6.80, which Gaia measures at 24.47 and 28.25. Others sat beside a Gaia entry
|
||||||
|
* a Hipparcos row of HYG's had already taken, HYG listing the star twice: Gl 251 at 5.76 pc beside
|
||||||
|
* HD 265866, the host of GJ 251 b and c, at 5.58.
|
||||||
|
*
|
||||||
|
* The fold keeps the entry already there's photometry along with Gaia's position and distance, and
|
||||||
|
* HYG's name and type. Taking the Gliese row's, as {@link combine} would, put CNS3's V at Gaia's
|
||||||
|
* distance: GJ 4285 at V 11.45, where its G 13.05 and BP−RP 2.74 give 14.4, drawn five times too
|
||||||
|
* luminous, and six stars with no colour lost their temperature and radius, Gl 700.1C among them.
|
||||||
|
*
|
||||||
|
* Unless SIMBAD names the HYG star already there as another source: then HYG hung it on the wrong
|
||||||
|
* one, and the Gliese row is the star that source is, so its description — photometry included —
|
||||||
|
* replaces that one. HIP 117059 is LAWD 93, the white dwarf Gl 905.2B (DA, V 12.94 in SIMBAD), which
|
||||||
|
* HYG labels "Gl 905.2A", M5, V 13.11, B−V 1.55: kept, the white dwarf was drawn as a 3 384 K red
|
||||||
|
* dwarf 15 times its radius. Five of the 63 folds are of this kind, GJ 9490C, Gl 225.2C, 69 Tau Oph
|
||||||
|
* A and HD 65277 (Gl 293.1A) the others; each HYG star SIMBAD puts elsewhere loses its entry,
|
||||||
|
* having no source of its own on the map to carry it.
|
||||||
|
*/
|
||||||
|
export function foldByIdentity(stars: readonly StarRecord[], gaiaDesignationById: ReadonlyMap<number, string>): { stars: StarRecord[]; folded: number } {
|
||||||
|
const byDesignation = new Map<string, number>();
|
||||||
|
stars.forEach((star, index) => {
|
||||||
|
if (star.gaiaDesignation !== undefined) {
|
||||||
|
byDesignation.set(star.gaiaDesignation, index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const result = [...stars];
|
||||||
|
const folded = new Set<number>();
|
||||||
|
stars.forEach((star, index) => {
|
||||||
|
const designation = star.source === 'hyg' && star.distanceError === undefined ? gaiaDesignationById.get(star.id) : undefined;
|
||||||
|
const target = designation === undefined ? undefined : byDesignation.get(designation);
|
||||||
|
if (target === undefined || !foldsInto(result[target], star)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const describer = gaiaDesignationById.get(result[target].id);
|
||||||
|
const { magnitude, magnitudeBand, colorIndex, colorSystem } = result[target];
|
||||||
|
// Where the Gliese row is the star, its colour too, or none: not the one of the star SIMBAD puts elsewhere.
|
||||||
|
result[target] =
|
||||||
|
describer !== undefined && describer !== designation
|
||||||
|
? { ...combine(result[target], star, star), colorIndex: star.colorIndex, colorSystem: star.colorSystem }
|
||||||
|
: { ...combine(result[target], star), magnitude, magnitudeBand, colorIndex, colorSystem };
|
||||||
|
// One star each: a second Gliese row naming the same source is another star SIMBAD has not split.
|
||||||
|
byDesignation.delete(designation!);
|
||||||
|
folded.add(index);
|
||||||
|
});
|
||||||
|
return { stars: result.filter((_, index) => !folded.has(index)), folded: folded.size };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { absoluteMagnitude, bolometricCorrection, luminositySolar, SOLAR_ABSOLUTE_MAGNITUDE_V, SOLAR_BOLOMETRIC_MAGNITUDE } from './stellar';
|
import {
|
||||||
|
absoluteMagnitude,
|
||||||
|
blackbodyColor,
|
||||||
|
bolometricCorrection,
|
||||||
|
effectiveTemperatureK,
|
||||||
|
giantSurface,
|
||||||
|
luminositySolar,
|
||||||
|
radiusFromLuminositySolar,
|
||||||
|
SOLAR_ABSOLUTE_MAGNITUDE_V,
|
||||||
|
SOLAR_BOLOMETRIC_MAGNITUDE,
|
||||||
|
SOLAR_EFFECTIVE_TEMPERATURE_K
|
||||||
|
} from './stellar';
|
||||||
|
import { dwarfSequenceAtType } from './spectral';
|
||||||
|
|
||||||
/** Real catalogue rows, with the published luminosity each one should reproduce. */
|
/** 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 SIRIUS = { magnitude: -1.44, distancePc: 2.6371, spectralType: 'A0m...', publishedLuminosity: 25.4 };
|
||||||
@@ -96,6 +108,98 @@ describe('luminositySolar', () => {
|
|||||||
expect(luminositySolar(PROXIMA)!).toBeGreaterThan(uncorrected * 5);
|
expect(luminositySolar(PROXIMA)!).toBeGreaterThan(uncorrected * 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads the correction off the colour where the catalogue has no type', () => {
|
||||||
|
// Barnard's Star, 0.0035 L☉ (Dawson & De Robertis 2004), as a star no one classified: the
|
||||||
|
// Sun's correction left it at an eighth of that.
|
||||||
|
const derived = luminositySolar({ magnitude: 9.54, distancePc: 1.8266, spectralType: 'Unknown', magnitudeBand: 'V', colorIndex: 1.57, colorSystem: 'B-V' })!;
|
||||||
|
expect(derived / 0.0035).toBeGreaterThan(1 / 1.5);
|
||||||
|
expect(derived / 0.0035).toBeLessThan(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries a Gaia G magnitude to V before correcting it', () => {
|
||||||
|
// TRAPPIST-1 as Gaia has it, 5.53e-4 L☉ (Agol et al. 2021). Read as V its G is 3.1
|
||||||
|
// magnitudes too bright, and its luminosity comes out seventeen times too high.
|
||||||
|
const derived = luminositySolar({ magnitude: 15.6226, distancePc: 12.467, spectralType: 'Unknown', magnitudeBand: 'G', colorIndex: 4.902, colorSystem: 'BP-RP' })!;
|
||||||
|
expect(derived / 5.53e-4).toBeGreaterThan(1 / 1.5);
|
||||||
|
expect(derived / 5.53e-4).toBeLessThan(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a giant's temperature and correction both off its type, not the cooler dwarf's its colour reads as", () => {
|
||||||
|
// Antares, M1 Ib at B−V 1.87: 3 660 K (Ohnaka et al. 2013), where its colour's dwarf is 3 019.
|
||||||
|
const antares = { magnitude: 1.06, distancePc: 169.78, spectralType: 'M1Ib + B2.5V', magnitudeBand: 'V', colorIndex: 1.865, colorSystem: 'B-V' } as const;
|
||||||
|
expect(Math.abs(effectiveTemperatureK(antares)! - 3660)).toBeLessThan(100);
|
||||||
|
// Each piece of van Belle et al.'s (2021) table 8, at G0 = 50, K0 = 60, M0 = 66 on its index:
|
||||||
|
// G8 III 7856 − 52.74 × 58, K2 III 16751 − 199.41 × 62, and flat at 3 134 K from M6 III.
|
||||||
|
const giant = (spectralType: string) => effectiveTemperatureK({ magnitude: 5, distancePc: 100, spectralType, colorIndex: null });
|
||||||
|
expect(giant('G8III')).toBeCloseTo(4797.08, 1);
|
||||||
|
expect(giant('K2III')).toBeCloseTo(4387.58, 1);
|
||||||
|
expect(giant('M5.5III')).toBeCloseTo(3343.43, 1);
|
||||||
|
expect(giant('M6III')).toBe(3134);
|
||||||
|
expect(giant('M7III')).toBe(3134);
|
||||||
|
// Aldebaran, K5 III, 44.2 R☉ (Richichi & Roccatagliata 2005), to a tenth; Rigel, B8 Ia, 74.1
|
||||||
|
// (Baines et al. 2018), to a fifth. With a correction off the type beside the colour's
|
||||||
|
// temperature, Rigel came out 101.6; with K5's own correction at 3 902 K, Aldebaran 52.
|
||||||
|
const aldebaran = { magnitude: 0.87, distancePc: 20.433, spectralType: 'K5III', magnitudeBand: 'V', colorIndex: 1.538, colorSystem: 'B-V' } as const;
|
||||||
|
const rigel = { magnitude: 0.18, distancePc: 264.55, spectralType: 'B8Ia', magnitudeBand: 'V', colorIndex: -0.03, colorSystem: 'B-V' } as const;
|
||||||
|
for (const [star, published, tolerance] of [[aldebaran, 44.2, 1.1], [rigel, 74.1, 1.2]] as const) {
|
||||||
|
const radius = radiusFromLuminositySolar(luminositySolar(star)!, effectiveTemperatureK(star)!);
|
||||||
|
expect(radius / published).toBeGreaterThan(1 / tolerance);
|
||||||
|
expect(radius / published).toBeLessThan(tolerance);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws an M6 giant at the radius its measured diameter gives, with an M giant's correction, not a dwarf's", () => {
|
||||||
|
// RZ Ari (ρ² Ari) and EU Del as the catalogue has them, and their limb-darkened diameters in
|
||||||
|
// CHARM2 (Richichi et al. 2005), 10.30 and 9.90 mas: 119 and 126 R☉ at those distances. With the
|
||||||
|
// dwarf's −2.76 at 3 134 K they came out 81.5 and 72.6; van Belle's own M6 giants give −3.94.
|
||||||
|
const rzAri = { magnitude: 5.76, distancePc: 107.76, spectralType: 'M6IIIvar', magnitudeBand: 'V', colorIndex: 1.452, colorSystem: 'B-V' } as const;
|
||||||
|
const euDel = { magnitude: 6.22, distancePc: 118.6, spectralType: 'M6III', magnitudeBand: 'V', colorIndex: 1.162, colorSystem: 'B-V' } as const;
|
||||||
|
for (const [star, diameterMas] of [[rzAri, 10.3], [euDel, 9.9]] as const) {
|
||||||
|
const measured = 0.10753 * diameterMas * star.distancePc;
|
||||||
|
const radius = radiusFromLuminositySolar(luminositySolar(star)!, effectiveTemperatureK(star)!);
|
||||||
|
expect(radius / measured).toBeGreaterThan(1 / 1.2);
|
||||||
|
expect(radius / measured).toBeLessThan(1.2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws van Belle's own M4, M5 and M7 giants at their measured radii, and holds the correction past M7.5", () => {
|
||||||
|
// Median Johnson V, parallax and radius from van Belle et al. (2021) tables 6 and 4, none behind
|
||||||
|
// more than 0.02 mag of dust: 103.1, 103.2 and 173.3 R☉, drawn at 1.00, 1.00 and 0.91 of that.
|
||||||
|
// A dwarf's correction draws M4 18 % smaller and M5 26 %, and M6's −3.94 draws M7 25 % smaller;
|
||||||
|
// hence 15 %, not the M6 case's 20 %.
|
||||||
|
const giants = [
|
||||||
|
[{ magnitude: 7.72, distancePc: 1000 / 1.87, spectralType: 'M4III', magnitudeBand: 'V' }, 103.11], // HD 118669
|
||||||
|
[{ magnitude: 6.97, distancePc: 1000 / 3.45, spectralType: 'M5III', magnitudeBand: 'V' }, 103.15], // HD 104207
|
||||||
|
[{ magnitude: 9.29, distancePc: 1000 / 2.17, spectralType: 'M7III', magnitudeBand: 'V' }, 173.31] // HIP 68357
|
||||||
|
] as const;
|
||||||
|
for (const [star, measured] of giants) {
|
||||||
|
const radius = radiusFromLuminositySolar(luminositySolar(star)!, effectiveTemperatureK(star)!);
|
||||||
|
expect(radius / measured).toBeGreaterThan(1 / 1.15);
|
||||||
|
expect(radius / measured).toBeLessThan(1.15);
|
||||||
|
}
|
||||||
|
// Six M8 III stars in the catalogue, past the table's last type, take its −4.86, not its first.
|
||||||
|
expect(giantSurface('M8III')!.bolometricCorrectionV).toBe(giantSurface('M7.5III')!.bolometricCorrectionV);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a hot giant reddened by dust at its type, not at the cool star its colour reads as', () => {
|
||||||
|
// Menkib, O7.5 Iab at B−V 0.02: 14 R☉ (Krtička & Kubát 2010). At its colour's 9 517 K and its
|
||||||
|
// type's correction it was drawn at 95; the dust it is behind still leaves it dimmer than it is.
|
||||||
|
const menkib = { magnitude: 3.98, distancePc: 408.881, spectralType: 'O7.5Iab:', magnitudeBand: 'V', colorIndex: 0.016, colorSystem: 'B-V' } as const;
|
||||||
|
expect(effectiveTemperatureK(menkib)).toBeCloseTo(36100, 6);
|
||||||
|
const radius = radiusFromLuminositySolar(luminositySolar(menkib)!, effectiveTemperatureK(menkib)!);
|
||||||
|
expect(radius / 14).toBeGreaterThan(1 / 2.5);
|
||||||
|
expect(radius / 14).toBeLessThan(2.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a carbon star the carbon stars' correction and temperature, not the Sun's correction at an M dwarf's", () => {
|
||||||
|
// La Superba, C7 Iab: Bergeat et al. (2001) have it at bolometric magnitude 2.43, which at the
|
||||||
|
// catalogue's 310 pc is 8 090 L☉. The Sun's −0.06 at 2 420 K gave 544 L☉ and 133 R☉.
|
||||||
|
const laSuperba = { magnitude: 5.42, distancePc: 310.342, spectralType: 'C7Iab', magnitudeBand: 'V', colorIndex: 2.994, colorSystem: 'B-V' } as const;
|
||||||
|
expect(luminositySolar(laSuperba)! / 8090).toBeGreaterThan(1 / 1.2);
|
||||||
|
expect(luminositySolar(laSuperba)! / 8090).toBeLessThan(1.2);
|
||||||
|
expect(effectiveTemperatureK(laSuperba)).toBe(2990);
|
||||||
|
});
|
||||||
|
|
||||||
it('clamps a pathological record instead of producing an absurd luminosity', () => {
|
it('clamps a pathological record instead of producing an absurd luminosity', () => {
|
||||||
const absurd = luminositySolar({ magnitude: -40, distancePc: 5000, spectralType: 'O5V' })!;
|
const absurd = luminositySolar({ magnitude: -40, distancePc: 5000, spectralType: 'O5V' })!;
|
||||||
expect(Number.isFinite(absurd)).toBe(true);
|
expect(Number.isFinite(absurd)).toBe(true);
|
||||||
@@ -106,3 +210,113 @@ describe('luminositySolar', () => {
|
|||||||
expect(luminositySolar({ magnitude: 5, distancePc: -1 })).toBeNull();
|
expect(luminositySolar({ magnitude: 5, distancePc: -1 })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('effectiveTemperatureK', () => {
|
||||||
|
it("is the Sun's own for the Sun", () => {
|
||||||
|
expect(effectiveTemperatureK({ magnitude: -26.7, distancePc: 0, colorIndex: 0.7 })).toBe(SOLAR_EFFECTIVE_TEMPERATURE_K);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a colour in its own system, and a spectral type where there is no colour', () => {
|
||||||
|
// An M5 dwarf is 3 060 K at B−V 1.83 or BP−RP 3.35, and at its type alone.
|
||||||
|
expect(effectiveTemperatureK({ magnitude: 11, distancePc: 5, colorIndex: 3.35, colorSystem: 'BP-RP' })).toBeCloseTo(3060, 0);
|
||||||
|
expect(effectiveTemperatureK({ magnitude: 11, distancePc: 5, colorIndex: 1.83, colorSystem: 'B-V' })).toBeCloseTo(3060, 0);
|
||||||
|
expect(effectiveTemperatureK({ magnitude: 11, distancePc: 5, spectralType: 'M5Ve', colorIndex: null })).toBeCloseTo(3060, 0);
|
||||||
|
expect(effectiveTemperatureK({ magnitude: 11, distancePc: 5, spectralType: 'Unknown', colorIndex: null })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a colour past the table at its end where there is no type, and the type where there is', () => {
|
||||||
|
// An ultracool dwarf redder than M8.5, a white dwarf bluer than B9 at the 19 012 K Gentile
|
||||||
|
// Fusillo et al. (2021) measure at its colour, not B9's 10 700, and an O star at its type's
|
||||||
|
// 35 100 K, where B−V puts every O star at B0's 31 400.
|
||||||
|
expect(effectiveTemperatureK({ magnitude: 14.005, distancePc: 4.005, spectralType: 'Unknown', magnitudeBand: 'G', colorIndex: 5.113, colorSystem: 'BP-RP' })).toBe(2420);
|
||||||
|
expect(effectiveTemperatureK({ magnitude: 14, distancePc: 25, spectralType: 'Unknown', magnitudeBand: 'G', colorIndex: -0.25, colorSystem: 'BP-RP' })).toBeCloseTo(19012, 6);
|
||||||
|
expect(effectiveTemperatureK({ magnitude: 7, distancePc: 121, spectralType: 'O8', colorIndex: -0.31, colorSystem: 'B-V' })).toBe(35100);
|
||||||
|
// HD 49748, G5 V at B−V −0.32: the colour is the one that is wrong.
|
||||||
|
const g5 = effectiveTemperatureK({ magnitude: 9, distancePc: 184, spectralType: 'G5V', colorIndex: null })!;
|
||||||
|
expect(effectiveTemperatureK({ magnitude: 9, distancePc: 184, spectralType: 'G5V', colorIndex: -0.319, colorSystem: 'B-V' })).toBe(g5);
|
||||||
|
expect(g5).toBeGreaterThan(5500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a dwarf with a type and no colour', () => {
|
||||||
|
it("is drawn at its type's row of the dwarf sequence, not at the textbook colour of its type", () => {
|
||||||
|
// GJ 3655, M8 at V 19.57 and 14.35 pc: M8 V is 2 570 K and 0.114 R☉ (Mamajek's table, 2022.04.16).
|
||||||
|
// Through the textbook colour, B−V 1.88, the table's M5, and the textbook correction, −3.92
|
||||||
|
// where M8's is −5.65, it was 3 001 K and 0.035 R☉, a third of Jupiter.
|
||||||
|
const gj3655 = { magnitude: 19.57, distancePc: 14.35, spectralType: 'M8', magnitudeBand: 'V', colorIndex: null } as const;
|
||||||
|
expect(effectiveTemperatureK(gj3655)).toBeCloseTo(2570, 6);
|
||||||
|
const radius = radiusFromLuminositySolar(luminositySolar(gj3655)!, effectiveTemperatureK(gj3655)!);
|
||||||
|
expect(radius / 0.114).toBeGreaterThan(1 / 1.2);
|
||||||
|
expect(radius / 0.114).toBeLessThan(1.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries a G magnitude to V at its type's G−V", () => {
|
||||||
|
// The same star measured in G, which for an M8 dwarf reads 3.11 magnitudes brighter than V: taken
|
||||||
|
// as V, it came out 17 times as luminous. One published star takes this path, Oph 11 (M9, G 18.91).
|
||||||
|
const inV = { magnitude: 19.57, distancePc: 14.35, spectralType: 'M8', magnitudeBand: 'V', colorIndex: null } as const;
|
||||||
|
const inG = { ...inV, magnitude: 19.57 + dwarfSequenceAtType('M8')!.gMinusV!, magnitudeBand: 'G' } as const;
|
||||||
|
expect(dwarfSequenceAtType('M8')!.gMinusV).toBeLessThan(-3);
|
||||||
|
expect(luminositySolar(inG)!).toBeCloseTo(luminositySolar(inV)!, 12);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('radiusFromLuminositySolar', () => {
|
||||||
|
it('is one for the Sun', () => {
|
||||||
|
expect(radiusFromLuminositySolar(1, SOLAR_EFFECTIVE_TEMPERATURE_K)).toBeCloseTo(1, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives an ultracool dwarf redder than the table an M8.5 dwarf's radius, not none", () => {
|
||||||
|
// Gaia DR3 6439125097427143808, 4.0 pc away at BP−RP 5.11; M8.5 V is 0.104 R☉ (Mamajek).
|
||||||
|
const star = { magnitude: 14.005, distancePc: 4.005, spectralType: 'Unknown', magnitudeBand: 'G', colorIndex: 5.113, colorSystem: 'BP-RP' } as const;
|
||||||
|
const radius = radiusFromLuminositySolar(luminositySolar(star)!, effectiveTemperatureK(star)!);
|
||||||
|
expect(radius / 0.104).toBeGreaterThan(1 / 1.2);
|
||||||
|
expect(radius / 0.104).toBeLessThan(1.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a white dwarf bluer than the table the radius its mass and gravity give', () => {
|
||||||
|
// Gaia DR3 6791196382856581376, 24.5 pc: 19 205 K, log g 8.07 and 0.66 M☉ in Gentile Fusillo et
|
||||||
|
// al. (2021), so 0.01245 R☉. At B9's 10 700 K it came out about 1.5 times that.
|
||||||
|
const star = { magnitude: 12.9198, distancePc: 24.5237, spectralType: 'Unknown', magnitudeBand: 'G', colorIndex: -0.2539, colorSystem: 'BP-RP' } as const;
|
||||||
|
const radius = radiusFromLuminositySolar(luminositySolar(star)!, effectiveTemperatureK(star)!);
|
||||||
|
expect(radius / 0.01245).toBeGreaterThan(1 / 1.2);
|
||||||
|
expect(radius / 0.01245).toBeLessThan(1.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives Sirius and TRAPPIST-1 their published radii from colour and brightness alone', () => {
|
||||||
|
// 1.711 R☉ (Liebert et al. 2005) and 0.119 R☉ (Agol et al. 2021), each to within a fifth.
|
||||||
|
for (const [star, published] of [
|
||||||
|
[{ magnitude: -1.44, distancePc: 2.6371, magnitudeBand: 'V', colorIndex: 0.009, colorSystem: 'B-V' }, 1.711],
|
||||||
|
[{ magnitude: 15.6226, distancePc: 12.467, magnitudeBand: 'G', colorIndex: 4.902, colorSystem: 'BP-RP' }, 0.119]
|
||||||
|
] as const) {
|
||||||
|
const radius = radiusFromLuminositySolar(luminositySolar(star)!, effectiveTemperatureK(star)!);
|
||||||
|
expect(radius / published).toBeGreaterThan(0.8);
|
||||||
|
expect(radius / published).toBeLessThan(1.2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('blackbodyColor', () => {
|
||||||
|
/** As the display shows it: sRGB-encoded, 0 to 255. */
|
||||||
|
const displayed = (rgb: readonly number[]) => rgb.map((v) => Math.round(255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055)));
|
||||||
|
|
||||||
|
it('gives the colours of the stars against the display white', () => {
|
||||||
|
// Charity's blackbody colour table (CIE 1931 2°, D65): 2 900 K #ffb662, 5 800 K #fff1e7, 9 600 K #d3ddff.
|
||||||
|
for (const [temperatureK, expected] of [[2900, [255, 182, 98]], [5800, [255, 241, 231]], [9600, [211, 221, 255]]] as const) {
|
||||||
|
displayed(blackbodyColor(temperatureK)).forEach((channel, i) => expect(Math.abs(channel - expected[i])).toBeLessThanOrEqual(5));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is white at the white point it is given, and an M dwarf's light orange-red against the Sun's", () => {
|
||||||
|
expect(blackbodyColor(SOLAR_EFFECTIVE_TEMPERATURE_K, SOLAR_EFFECTIVE_TEMPERATURE_K)).toEqual([1, 1, 1]);
|
||||||
|
const [r, g, b] = blackbodyColor(2566, SOLAR_EFFECTIVE_TEMPERATURE_K);
|
||||||
|
expect(r).toBe(1);
|
||||||
|
expect(g).toBeCloseTo(0.44, 2);
|
||||||
|
expect(b).toBeCloseTo(0.1, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds the ends of the fit, and never goes negative', () => {
|
||||||
|
expect(blackbodyColor(800)).toEqual(blackbodyColor(1667));
|
||||||
|
expect(blackbodyColor(60000)).toEqual(blackbodyColor(25000));
|
||||||
|
expect(Math.min(...blackbodyColor(1667))).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { parseSpectralClass, SpectralClass } from './spectral';
|
import { DwarfSequencePoint, dwarfSequenceAtColor, dwarfSequenceAtTemperature, dwarfSequenceAtType, isGiant, parseSpectralClass, SpectralClass } from './spectral';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stellar luminosity, derived from the two things the star catalogue actually measures.
|
* Stellar luminosity, derived from the two things the star catalogue actually measures.
|
||||||
@@ -91,11 +91,15 @@ export function bolometricCorrection(spectralType: string | null | undefined): n
|
|||||||
|
|
||||||
/** Everything about a star that bears on how much light it puts out. */
|
/** Everything about a star that bears on how much light it puts out. */
|
||||||
export interface StellarPhotometry {
|
export interface StellarPhotometry {
|
||||||
/** Apparent visual magnitude, as catalogued. */
|
/** Apparent magnitude, as catalogued, in `magnitudeBand`. */
|
||||||
magnitude: number;
|
magnitude: number;
|
||||||
/** Distance from the Sun in parsecs; `0` identifies the Sun itself. */
|
/** Distance from the Sun in parsecs; `0` identifies the Sun itself. */
|
||||||
distancePc: number;
|
distancePc: number;
|
||||||
spectralType?: string;
|
spectralType?: string;
|
||||||
|
/** V, or Gaia's G — which for an M5 dwarf reads 1.7 magnitudes brighter. Taken as V if absent. */
|
||||||
|
magnitudeBand?: 'V' | 'G';
|
||||||
|
colorIndex?: number | null;
|
||||||
|
colorSystem?: 'B-V' | 'BP-RP';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,7 +122,205 @@ export function luminositySolar(star: StellarPhotometry): number | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bolometric = absolute + bolometricCorrection(star.spectralType);
|
// Where the star has a colour the dwarf sequence covers, its correction is read off that colour,
|
||||||
|
// and a G magnitude is carried to V first; otherwise both are read off the same table at its
|
||||||
|
// spectral type, and only with neither is a G magnitude taken as V. Gaia classifies none of its
|
||||||
|
// stars, so every one of them used to be given the Sun's correction, and TRAPPIST-1 came out at
|
||||||
|
// a seventh of its luminosity. Against the archive's own figure for 1 449 hosts, the worst tenth
|
||||||
|
// was off by 0.29 dex or more, and is now off by 0.12. A type with no colour took the textbook
|
||||||
|
// anchors below instead, M8 −3.92 where the table has −5.65: GJ 3655, M8, came out 8.9×10⁻⁵ L☉
|
||||||
|
// at 3 001 K and 0.035 R☉, where its type's row gives 2 570 K and 0.106.
|
||||||
|
//
|
||||||
|
// Not for a star its type says is a giant, though, whose correction is read off its type along
|
||||||
|
// with its temperature: see giantSurface.
|
||||||
|
const sequence = sequenceAtColour(star) ?? dwarfSequenceAtType(star.spectralType);
|
||||||
|
const absoluteV = absolute - (star.magnitudeBand === 'G' ? (sequence?.gMinusV ?? 0) : 0);
|
||||||
|
const correction = giantSurface(star.spectralType)?.bolometricCorrectionV ?? sequence?.bolometricCorrectionV ?? bolometricCorrection(star.spectralType);
|
||||||
|
const bolometric = absoluteV + correction;
|
||||||
const luminosity = Math.pow(10, (SOLAR_BOLOMETRIC_MAGNITUDE - bolometric) / 2.5);
|
const luminosity = Math.pow(10, (SOLAR_BOLOMETRIC_MAGNITUDE - bolometric) / 2.5);
|
||||||
return Math.min(Math.max(luminosity, MIN_LUMINOSITY_SOLAR), MAX_LUMINOSITY_SOLAR);
|
return Math.min(Math.max(luminosity, MIN_LUMINOSITY_SOLAR), MAX_LUMINOSITY_SOLAR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dwarf sequence at a star's colour — past the table's end, where the star has no type to go
|
||||||
|
* by instead, at the end a colour is past. Past the red end are the ultracool dwarfs Gaia measures
|
||||||
|
* redder than BP−RP 5.1, M8.5, and past B−V's blue end its O stars; Gaia's white dwarfs, bluer
|
||||||
|
* than BP−RP −0.12, are read at the temperature white dwarfs of their colour are measured at.
|
||||||
|
* Unread, they had no temperature and were drawn at the Sun's: Gaia DR3 6439125097427143808, an
|
||||||
|
* ultracool dwarf 4.0 pc away, and 110 white dwarfs within 50 pc, all at 1 R☉. Beside a type, an
|
||||||
|
* off-table colour is more often a bad one than an extreme star — HD 49748, G5 V, at B−V −0.32 —
|
||||||
|
* and the type is read instead.
|
||||||
|
*/
|
||||||
|
function sequenceAtColour(star: Pick<StellarPhotometry, 'spectralType' | 'colorIndex' | 'colorSystem'>): DwarfSequencePoint | null {
|
||||||
|
if (star.colorIndex == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return dwarfSequenceAtColor(star.colorIndex, star.colorSystem) ?? (parseSpectralClass(star.spectralType) ? null : dwarfSequenceAtColor(star.colorIndex, star.colorSystem, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Sun's effective temperature, the IAU 2015 nominal value. */
|
||||||
|
export const SOLAR_EFFECTIVE_TEMPERATURE_K = 5772;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective temperature, off the dwarf sequence at the star's colour, or at its spectral type where
|
||||||
|
* it has none; a giant's off its type (giantSurface). Exactly the Sun's for the Sun, which is at
|
||||||
|
* zero distance here. The type was read through the textbook colour `spectralTypeToColorIndex`
|
||||||
|
* gives it, which the table puts elsewhere: M8's 1.88 is M5's, 3 001 K where M8 is 2 570, and every
|
||||||
|
* O type came out B0's 31 400.
|
||||||
|
*/
|
||||||
|
export function effectiveTemperatureK(star: StellarPhotometry): number | null {
|
||||||
|
if (star.distancePc === 0) {
|
||||||
|
return SOLAR_EFFECTIVE_TEMPERATURE_K;
|
||||||
|
}
|
||||||
|
return giantSurface(star.spectralType)?.temperatureK ?? (sequenceAtColour(star) ?? dwarfSequenceAtType(star.spectralType))?.temperatureK ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether {@link effectiveTemperatureK} reads the star off its colour rather than off its type: not
|
||||||
|
* for a giant, nor for a star with no colour the table reads, which since 206e88a is placed at its
|
||||||
|
* type's row: 224 stars that are not giants with no colour, and 36 whose colour is off the table
|
||||||
|
* (HD 49748, G5 V at B−V −0.32). The card said their radii came "from colour and brightness".
|
||||||
|
*/
|
||||||
|
export function temperatureFromColour(star: Pick<StellarPhotometry, 'spectralType' | 'colorIndex' | 'colorSystem'>): boolean {
|
||||||
|
return giantSurface(star.spectralType) === null && sequenceAtColour(star) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a giant's type says of its surface: its effective temperature, and its bolometric
|
||||||
|
* correction — the dwarf sequence's at that temperature, or from M0 an M giant's own
|
||||||
|
* ({@link M_GIANT_CORRECTIONS}) — both off the type, so that the two a radius is drawn from come
|
||||||
|
* from the same place. `null` for a star that is not a giant, or whose class the parser cannot read.
|
||||||
|
*
|
||||||
|
* Read off the colour, a giant is the dwarf of its colour, too cool: Antares, M1 Ib at B−V 1.87,
|
||||||
|
* came out 3 019 K against the 3 660 Ohnaka et al. (2013) measure, and the 610 M giants a median
|
||||||
|
* 3 275 K where M2 III is about 3 650. Worse, a hot giant behind dust reads as a far cooler star:
|
||||||
|
* Menkib, O7.5 Iab at B−V 0.02, was 9 517 K, and with its type's correction beside that colour's
|
||||||
|
* temperature it was drawn at 95 R☉, and Alp Cam, O9.5 Ia, at 338, where 14 and 21 are published.
|
||||||
|
*
|
||||||
|
* G to M giants take van Belle et al.'s (2021, ApJ 922, 163, table 8) interferometric scale, fitted
|
||||||
|
* to 191 giants from G1 to M7.75 III: 4 797 K at G8, 4 388 at K2, 3 816 at M0, 3 472 at M4, and held
|
||||||
|
* at 3 134 K from M6, where its own M6 and M7 giants average 3 112 and 3 114 K; carried on to M7.9
|
||||||
|
* instead, the M6 giants were 3 300 K. O to F giants take the dwarf of their type, which a
|
||||||
|
* supergiant of the same type is within a few per cent of from B8 on, and a few thousand kelvin
|
||||||
|
* cooler than at B0 (Alnilam, B0 Ia, about 27 000 K against B0 V's 31 400). Carbon and S stars take {@link CARBON_STAR}.
|
||||||
|
*/
|
||||||
|
export function giantSurface(spectralType: string | null | undefined): GiantSurface | null {
|
||||||
|
// ponytail: kept per type string, unbounded; the catalogue has 2 888 of them. The star field asks
|
||||||
|
// for each of its 455 571 stars at boot, and the split and two regular expressions took 20-40 ms.
|
||||||
|
if (!GIANT_SURFACES.has(spectralType)) {
|
||||||
|
GIANT_SURFACES.set(spectralType, giantSurfaceOfType(spectralType));
|
||||||
|
}
|
||||||
|
return GIANT_SURFACES.get(spectralType)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GiantSurface = { temperatureK: number; bolometricCorrectionV: number };
|
||||||
|
const GIANT_SURFACES = new Map<string | null | undefined, GiantSurface | null>();
|
||||||
|
|
||||||
|
function giantSurfaceOfType(spectralType: string | null | undefined): GiantSurface | null {
|
||||||
|
if (!isGiant(spectralType)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const primary = (spectralType ?? '').split('+')[0].trim();
|
||||||
|
if (/^[CNRS]/.test(primary)) {
|
||||||
|
return CARBON_STAR;
|
||||||
|
}
|
||||||
|
const parsed = parseSpectralClass(primary);
|
||||||
|
if (!parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { spectralClass, subclass } = parsed;
|
||||||
|
if (spectralClass === 'G' || spectralClass === 'K' || spectralClass === 'M') {
|
||||||
|
// van Belle's index: G0 at 50, K0 at 60, K5 at 65 and M0 at 66, so a K later than K5 falls between.
|
||||||
|
const index = spectralClass === 'G' ? 50 + subclass : spectralClass === 'K' ? 60 + Math.min(subclass, 5) + Math.max(subclass - 5, 0) / 5 : 66 + subclass;
|
||||||
|
const temperatureK = index <= 61 ? 7856 - 52.74 * index : index <= 64 ? 16751 - 199.41 * index : index < 72 ? 9491 - 85.98 * index : 3134;
|
||||||
|
return { temperatureK, bolometricCorrectionV: index < M_GIANT_CORRECTIONS[0][0] ? dwarfSequenceAtTemperature(temperatureK).bolometricCorrectionV : mGiantCorrection(index) };
|
||||||
|
}
|
||||||
|
const dwarf = dwarfSequenceAtType(primary)!;
|
||||||
|
return { temperatureK: dwarf.temperatureK, bolometricCorrectionV: dwarf.bolometricCorrectionV };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An M giant's bolometric correction to V by van Belle's index: the median over each type of his own
|
||||||
|
* giants, m_bol from their table 4 fluxes (IAU 2015 zero point) less their dereddened Johnson V from
|
||||||
|
* table 6 — 18 at M0, 11 at M2, 11 at M3, 31 at M4, 15 at M5, 7 at M5.5, 3 at M6, and the 4 from M7
|
||||||
|
* to M7.75 at their mean index. Linear between, held past the ends. Down to M3 it is within 0.1 of
|
||||||
|
* the dwarf sequence's at the same temperature; past it TiO takes the V light and the two part, by
|
||||||
|
* 0.4 at M4, 1.2 at M6 and 2.1 at M7. Taken from the dwarfs, RZ Ari (M6 III) came out 81.5 R☉ against
|
||||||
|
* the 119 its 10.3 mas give at its distance, and EU Del 72.6 against 126.
|
||||||
|
*/
|
||||||
|
const M_GIANT_CORRECTIONS: readonly (readonly [number, number])[] = [
|
||||||
|
[66, -1.22],
|
||||||
|
[68, -1.5],
|
||||||
|
[69, -1.73],
|
||||||
|
[70, -2.2],
|
||||||
|
[71, -2.68],
|
||||||
|
[71.5, -3.34],
|
||||||
|
[72, -3.94],
|
||||||
|
[73.5, -4.86]
|
||||||
|
];
|
||||||
|
|
||||||
|
function mGiantCorrection(index: number): number {
|
||||||
|
const next = M_GIANT_CORRECTIONS.findIndex(([at]) => at >= index);
|
||||||
|
if (next <= 0) {
|
||||||
|
return M_GIANT_CORRECTIONS[next === 0 ? 0 : M_GIANT_CORRECTIONS.length - 1][1];
|
||||||
|
}
|
||||||
|
const [[fromIndex, from], [toIndex, to]] = [M_GIANT_CORRECTIONS[next - 1], M_GIANT_CORRECTIONS[next]];
|
||||||
|
return from + ((to - from) * (index - fromIndex)) / (toIndex - fromIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A carbon or S star's temperature and bolometric correction to V: the medians of Bergeat, Knapik &
|
||||||
|
* Rutily (2001, A&A 369, 178) over the 441 carbon stars of their table 10, and over the 383 of
|
||||||
|
* those with a V magnitude. No type in the table reads for them, and they were given the Sun's
|
||||||
|
* −0.06 at the M8.5 dwarf's 2 420 K: La Superba came out 544 L☉ and 133 R☉, where Bergeat's own
|
||||||
|
* figures give 8 090 L☉ at the same distance and McDonald et al. (2017) 315 R☉. S stars, between M
|
||||||
|
* and C, are given the carbon stars' figures for want of their own.
|
||||||
|
*/
|
||||||
|
const CARBON_STAR = { temperatureK: 2990, bolometricCorrectionV: -2.83 } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Radius in solar radii from luminosity and temperature — Stefan-Boltzmann, L = 4πR²σT⁴, in solar
|
||||||
|
* units. Luminosity-class blind, since the luminosity comes from the distance: a giant comes out a
|
||||||
|
* giant whatever the sequence took it for.
|
||||||
|
*/
|
||||||
|
export function radiusFromLuminositySolar(luminositySolar: number, temperatureK: number): number {
|
||||||
|
return Math.sqrt(luminositySolar) / (temperatureK / SOLAR_EFFECTIVE_TEMPERATURE_K) ** 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The range Kim et al.'s fit to the Planckian locus covers; a temperature outside it is clamped. */
|
||||||
|
const PLANCKIAN_LOCUS_MIN_K = 1667;
|
||||||
|
const PLANCKIAN_LOCUS_MAX_K = 25000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colour of a blackbody at `temperatureK`, in linear sRGB with its brightest channel at 1:
|
||||||
|
* its chromaticity off the Planckian locus (Kim et al. 2002, the cubic fit to CIE 1931), then
|
||||||
|
* CIE XYZ to sRGB. Against the display's own white, D65, unless `whitePointK` names the blackbody
|
||||||
|
* that is to read as white — as the Sun's does for the photographs of its planets, which were
|
||||||
|
* taken in its light.
|
||||||
|
*
|
||||||
|
* At D65, a 2 900 K M dwarf is sRGB (255, 180, 103), the Sun (255, 241, 234), a 9 600 K A star
|
||||||
|
* (208, 219, 255): Charity's table, which integrates the Planck spectrum, gives (255, 182, 98),
|
||||||
|
* (255, 241, 231) at 5 800 K and (211, 221, 255).
|
||||||
|
*/
|
||||||
|
export function blackbodyColor(temperatureK: number, whitePointK?: number): [number, number, number] {
|
||||||
|
const rgb = blackbodyLinearSrgb(temperatureK);
|
||||||
|
const white = whitePointK === undefined ? [1, 1, 1] : blackbodyLinearSrgb(whitePointK);
|
||||||
|
const relative = rgb.map((channel, i) => channel / white[i]);
|
||||||
|
const brightest = Math.max(...relative);
|
||||||
|
return relative.map((channel) => channel / brightest) as [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
function blackbodyLinearSrgb(temperatureK: number): number[] {
|
||||||
|
const t = 1000 / Math.min(Math.max(temperatureK, PLANCKIAN_LOCUS_MIN_K), PLANCKIAN_LOCUS_MAX_K);
|
||||||
|
const x =
|
||||||
|
t >= 0.25 ? -0.2661239 * t ** 3 - 0.2343589 * t ** 2 + 0.8776956 * t + 0.17991 : -3.0258469 * t ** 3 + 2.1070379 * t ** 2 + 0.2226347 * t + 0.24039;
|
||||||
|
const y =
|
||||||
|
t >= 1000 / 2222
|
||||||
|
? -1.1063814 * x ** 3 - 1.3481102 * x ** 2 + 2.18555832 * x - 0.20219683
|
||||||
|
: t >= 0.25
|
||||||
|
? -0.9549476 * x ** 3 - 1.37418593 * x ** 2 + 2.09137015 * x - 0.16748867
|
||||||
|
: 3.081758 * x ** 3 - 5.8733867 * x ** 2 + 3.75112997 * x - 0.37001483;
|
||||||
|
const [X, Y, Z] = [x / y, 1, (1 - x - y) / y];
|
||||||
|
// Below 1 920 K the locus leaves the sRGB gamut, and blue comes out negative.
|
||||||
|
return [3.2406 * X - 1.5372 * Y - 0.4986 * Z, -0.9689 * X + 1.8758 * Y + 0.0415 * Z, 0.0557 * X - 0.204 * Y + 1.057 * Z].map((channel) => Math.max(channel, 0));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { formatAu, formatDensity, formatLuminosity, formatMassEarth, formatParsecs, formatPeriod, formatRadiusKm, formatTemperature } from './quantity';
|
import { formatAu, formatDensity, formatDistance, formatLuminosity, formatMassEarth, formatParsecs, formatPeriod, formatRadiusKm, formatTemperature } from './quantity';
|
||||||
|
|
||||||
describe('formatParsecs', () => {
|
describe('formatParsecs', () => {
|
||||||
it('switches to kiloparsecs past a thousand parsecs', () => {
|
it('switches to kiloparsecs past a thousand parsecs', () => {
|
||||||
@@ -13,6 +13,33 @@ describe('formatParsecs', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatDistance', () => {
|
||||||
|
it('gives the error to the digits the distance is shown to', () => {
|
||||||
|
expect(formatDistance(117.3, 0.1)).toBe('117 ± 12 pc');
|
||||||
|
expect(formatDistance(4.2, 0.05)).toBe('4.20 ± 0.21 pc');
|
||||||
|
expect(formatDistance(1830, 0.15)).toBe('1.8 ± 0.3 kpc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves off an error of a per cent or less, or one too small to show', () => {
|
||||||
|
expect(formatDistance(117.3, 0.01)).toBe('117 pc');
|
||||||
|
expect(formatDistance(12, 0.03)).toBe('12 pc');
|
||||||
|
expect(formatDistance(117.3, undefined)).toBe('117 pc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a range once the error is a fifth of the parallax, and no upper end past all of it", () => {
|
||||||
|
// Alnilam: 1.65 ± 0.45 mas in Hipparcos.
|
||||||
|
expect(formatDistance(606.06, 0.45 / 1.65)).toBe('476 pc to 833 pc');
|
||||||
|
expect(formatDistance(250, 1)).toBe('125 pc or more');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an error on the distance itself symmetric, however large', () => {
|
||||||
|
// AT2021ueyL: 1 040 +740 −440 pc in the archive, whose mean is 57 % of it.
|
||||||
|
expect(formatDistance(1040, 0.567, true)).toBe('1.0 ± 0.6 kpc');
|
||||||
|
expect(formatDistance(134, 0.319, true)).toBe('134 ± 43 pc');
|
||||||
|
expect(formatDistance(134, 0.005, true)).toBe('134 pc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('formatAu', () => {
|
describe('formatAu', () => {
|
||||||
it('holds four decimals for close-in orbits', () => {
|
it('holds four decimals for close-in orbits', () => {
|
||||||
// 0.0026 AU is a real published semi-major axis; two decimals would render it — and every
|
// 0.0026 AU is a real published semi-major axis; two decimals would render it — and every
|
||||||
@@ -76,7 +103,11 @@ describe('formatTemperature and formatDensity', () => {
|
|||||||
describe('formatLuminosity', () => {
|
describe('formatLuminosity', () => {
|
||||||
it('stays decimal across the ordinary range', () => {
|
it('stays decimal across the ordinary range', () => {
|
||||||
expect(formatLuminosity(1)).toBe('1.00 L☉');
|
expect(formatLuminosity(1)).toBe('1.00 L☉');
|
||||||
expect(formatLuminosity(0.0017)).toBe('0.002 L☉');
|
expect(formatLuminosity(0.0017)).toBe('0.0017 L☉');
|
||||||
|
// Proxima's archive figure, which three decimals read as 0.002, a third over.
|
||||||
|
expect(formatLuminosity(0.0015100106)).toBe('0.0015 L☉');
|
||||||
|
expect(formatLuminosity(0.0523)).toBe('0.052 L☉');
|
||||||
|
expect(formatLuminosity(0.523)).toBe('0.523 L☉');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('goes to powers of ten at the extremes', () => {
|
it('goes to powers of ten at the extremes', () => {
|
||||||
|
|||||||
@@ -9,7 +9,41 @@
|
|||||||
|
|
||||||
/** Distance in parsecs, switching to kiloparsecs where the number would otherwise run long. */
|
/** Distance in parsecs, switching to kiloparsecs where the number would otherwise run long. */
|
||||||
export function formatParsecs(distancePc: number): string {
|
export function formatParsecs(distancePc: number): string {
|
||||||
return distancePc >= 1000 ? `${(distancePc / 1000).toFixed(1)} kpc` : `${distancePc.toFixed(distancePc < 10 ? 2 : 0)} pc`;
|
const { divisor, digits, unit } = parsecScale(distancePc);
|
||||||
|
return `${(distancePc / divisor).toFixed(digits)} ${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsecScale(distancePc: number): { divisor: number; digits: number; unit: string } {
|
||||||
|
return distancePc >= 1000 ? { divisor: 1000, digits: 1, unit: 'kpc' } : { divisor: 1, digits: distancePc < 10 ? 2 : 0, unit: 'pc' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A star's distance with its uncertainty, given as a fraction of it: `117 ± 12 pc`, to the
|
||||||
|
* digits the distance itself is shown to. Left off where it is 1 % or less, or would round to
|
||||||
|
* nothing at those digits, since the figure is then already as good as it reads.
|
||||||
|
*
|
||||||
|
* Past a fifth, a range: a distance inverted from a parallax takes the parallax's symmetric error
|
||||||
|
* bar as a lopsided one — Alnilam's 1.65 ± 0.45 mas is 476 to 833 pc, not 606 ± 165. Only a
|
||||||
|
* Hipparcos distance gets there; Gaia's query stops at a fifth. An error as large as the parallax
|
||||||
|
* leaves no upper bound at all.
|
||||||
|
*
|
||||||
|
* Not so where the error is on the distance itself, `onDistance`: the Exoplanet Archive's
|
||||||
|
* sy_disterr1 and 2, one-sided errors in parsecs, of which the catalogue keeps the mean. Many of
|
||||||
|
* those distances are no parallax at all — KMT-2016-BLG-1836L's 7.1 kpc comes from a lensing model
|
||||||
|
* — and read as one they gave 129 cards a range the archive does not: 5.8 to 9.2 kpc there, where
|
||||||
|
* it publishes 7 100 +800 −2 400 pc. They keep the ± at any size.
|
||||||
|
*/
|
||||||
|
export function formatDistance(distancePc: number, relativeError: number | undefined, onDistance = false): string {
|
||||||
|
if (relativeError === undefined || relativeError <= 0.01) {
|
||||||
|
return formatParsecs(distancePc);
|
||||||
|
}
|
||||||
|
if (relativeError < 0.2 || onDistance) {
|
||||||
|
const { divisor, digits, unit } = parsecScale(distancePc);
|
||||||
|
const error = ((distancePc * relativeError) / divisor).toFixed(digits);
|
||||||
|
return Number(error) === 0 ? formatParsecs(distancePc) : `${(distancePc / divisor).toFixed(digits)} ± ${error} ${unit}`;
|
||||||
|
}
|
||||||
|
const nearest = formatParsecs(distancePc / (1 + relativeError));
|
||||||
|
return relativeError >= 1 ? `${nearest} or more` : `${nearest} to ${formatParsecs(distancePc / (1 - relativeError))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Distance in astronomical units, for anything inside a system. */
|
/** Distance in astronomical units, for anything inside a system. */
|
||||||
@@ -62,13 +96,18 @@ export function formatDensity(gramsPerCm3: number): string {
|
|||||||
return `${gramsPerCm3.toFixed(2)} g/cm³`;
|
return `${gramsPerCm3.toFixed(2)} g/cm³`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bolometric luminosity in solar units, which spans many orders of magnitude. */
|
/**
|
||||||
|
* Bolometric luminosity in solar units, which spans many orders of magnitude. Two figures below a
|
||||||
|
* hundredth, as in the ×10ⁿ form below a thousandth: three decimals left one there, and Proxima's
|
||||||
|
* archive luminosity, 1.51×10⁻³ L☉, read 0.002, a third over; 23 of the 4 440 hosts the archive
|
||||||
|
* gives one for read more than 10 % off it.
|
||||||
|
*/
|
||||||
export function formatLuminosity(solar: number): string {
|
export function formatLuminosity(solar: number): string {
|
||||||
if (solar >= 1000 || (solar > 0 && solar < 0.001)) {
|
if (solar >= 1000 || (solar > 0 && solar < 0.001)) {
|
||||||
const exponent = Math.floor(Math.log10(solar));
|
const exponent = Math.floor(Math.log10(solar));
|
||||||
return `${(solar / Math.pow(10, exponent)).toFixed(1)}×10${superscript(exponent)} L☉`;
|
return `${(solar / Math.pow(10, exponent)).toFixed(1)}×10${superscript(exponent)} L☉`;
|
||||||
}
|
}
|
||||||
return `${solar.toFixed(solar < 1 ? 3 : 2)} L☉`;
|
return `${solar < 0.01 ? solar.toPrecision(2) : solar.toFixed(solar < 1 ? 3 : 2)} L☉`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function superscript(value: number): string {
|
function superscript(value: number): string {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Osculating Keplerian orbital elements at a reference epoch. Positions are derived
|
* Keplerian orbital elements at a reference epoch. Positions are derived client-side by
|
||||||
* client-side by propagating these elements forward/backward from `epochJd` (see
|
* propagating these elements forward/backward from `epochJd` (see `shared/astro/kepler.ts`),
|
||||||
* `shared/astro/kepler.ts`), rather than fetching per-frame positions.
|
* rather than fetching per-frame positions.
|
||||||
*/
|
*/
|
||||||
export interface OrbitalElements {
|
export interface OrbitalElements {
|
||||||
semiMajorAxisAu: number;
|
semiMajorAxisAu: number;
|
||||||
@@ -14,19 +14,127 @@ export interface OrbitalElements {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A solar-system planet, moon, or dwarf planet, sourced from JPL Horizons/SSD orbital
|
* How a body's mean elements move away from their epoch, per day.
|
||||||
* elements. `systemStarId` links back to the HYG star index (the Sun, see `SUN_STAR_ID`).
|
*
|
||||||
|
* Mean elements rather than one osculating set, because the map's clock runs decades in minutes.
|
||||||
|
* An osculating orbit is exact at its instant and drifts from then on: fed to Kepler with a mass
|
||||||
|
* ratio, the Moon's went round in 27.70 days instead of 27.32 and was 66 degrees out after a year.
|
||||||
|
* A mean set carries its own measured motion, and the slow turning of its node and periapsis, so
|
||||||
|
* it holds for as long as its source was fit over.
|
||||||
|
*/
|
||||||
|
export interface MeanElementRates {
|
||||||
|
/**
|
||||||
|
* How fast the body goes round in space, in degrees per day: the rate of its mean longitude.
|
||||||
|
* 360 over this is its sidereal period.
|
||||||
|
*/
|
||||||
|
meanMotionDegPerDay: number;
|
||||||
|
longitudeOfAscendingNodeDegPerDay: number;
|
||||||
|
argumentOfPeriapsisDegPerDay: number;
|
||||||
|
semiMajorAxisAuPerDay?: number;
|
||||||
|
eccentricityPerDay?: number;
|
||||||
|
inclinationDegPerDay?: number;
|
||||||
|
/**
|
||||||
|
* Standish's extra terms in the mean anomaly of Jupiter and beyond, `b T² + c cos(f T) +
|
||||||
|
* s sin(f T)` degrees, with T in Julian centuries from the epoch and f in degrees per century:
|
||||||
|
* the great-inequality wobble his 3000 BC to AD 3000 fit needs on top of its linear rates.
|
||||||
|
*/
|
||||||
|
meanAnomalyTerms?: { b: number; c: number; s: number; f: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A solar-system planet, moon, or dwarf planet: JPL mean orbital elements, and JPL Horizons
|
||||||
|
* physical data. `systemStarId` links back to the HYG star index (the Sun, see `SUN_STAR_ID`).
|
||||||
*/
|
*/
|
||||||
export interface BodyRecord {
|
export interface BodyRecord {
|
||||||
id: string;
|
id: string;
|
||||||
systemStarId: number;
|
systemStarId: number;
|
||||||
name: string;
|
name: string;
|
||||||
kind: 'planet' | 'moon' | 'dwarf';
|
kind: 'planet' | 'moon' | 'dwarf';
|
||||||
|
/** Mean radius: for a triaxial body, the radius of the sphere of its volume, which is how it is drawn. */
|
||||||
radiusKm: number;
|
radiusKm: number;
|
||||||
|
/**
|
||||||
|
* A triaxial body's three semi-axes, in km, largest first, where its shape is too far from a
|
||||||
|
* sphere for one radius to say it: Haumea's 1161 x 852 x 513 (Ortiz et al. 2017), whose mean
|
||||||
|
* radius is 798.
|
||||||
|
*/
|
||||||
|
semiAxesKm?: readonly [number, number, number];
|
||||||
|
/** Mean elements at `orbit.epochJd`, moving at `rates`. */
|
||||||
orbit: OrbitalElements;
|
orbit: OrbitalElements;
|
||||||
|
rates: MeanElementRates;
|
||||||
|
/**
|
||||||
|
* The pole of the plane a moon's elements are measured against, where that is its local
|
||||||
|
* Laplace plane or, for Uranus's and Pluto's moons, the planet's equator: right ascension and
|
||||||
|
* declination in the ICRF. The node is then counted from where that plane crosses the ICRF
|
||||||
|
* equator. Absent means the J2000 ecliptic, as for the planets and the Moon.
|
||||||
|
*/
|
||||||
|
laplacePole?: { raDeg: number; decDeg: number };
|
||||||
|
/** Where the elements come from and the span they hold over, as the card prints it. */
|
||||||
|
orbitSource: string;
|
||||||
|
/**
|
||||||
|
* The eccentricity the card prints, where it is not the orbit's own: Hyperion's row in the table
|
||||||
|
* its orbit is drawn from gives 0.0232, under a quarter of the 0.105 JPL's current table (SAT441)
|
||||||
|
* and Horizons (0.074 to 0.132 from 1980 to 2100) give. The older row still places Hyperion
|
||||||
|
* nearer where Horizons has it than the same row with 0.105 does, so the orbit keeps it.
|
||||||
|
*/
|
||||||
|
measuredEccentricity?: number;
|
||||||
/**
|
/**
|
||||||
* For `kind: 'moon'`, the `id` of the planet it orbits — its `orbit` is expressed
|
* For `kind: 'moon'`, the `id` of the planet it orbits — its `orbit` is expressed
|
||||||
* relative to that planet, not heliocentrically. Undefined for planets/dwarfs.
|
* relative to that planet, not heliocentrically. Undefined for planets/dwarfs.
|
||||||
*/
|
*/
|
||||||
parentBodyId?: string;
|
parentBodyId?: string;
|
||||||
|
/**
|
||||||
|
* For a moon heavy enough that it and its planet go round a point outside the planet — Charon,
|
||||||
|
* an eighth of Pluto's mass, puts it 2 100 km from Pluto's centre, 900 km above its surface —
|
||||||
|
* the moon's mass over the planet's, from the GMs on their Horizons pages. The planet's own
|
||||||
|
* elements then place that barycentre, as Standish's "Pluto" does, and both bodies are drawn
|
||||||
|
* going round it. Absent for every other moon.
|
||||||
|
*/
|
||||||
|
massRatio?: number;
|
||||||
|
/**
|
||||||
|
* How the body turns on its own axis: the sidereal rotation period in hours, negative where
|
||||||
|
* Horizons gives a negative rate (Venus, Uranus), and the tilt of that axis from its orbital
|
||||||
|
* plane — which past 90 degrees already says the turn is retrograde.
|
||||||
|
*
|
||||||
|
* For a locked moon the period is its orbit's, from the mean motion that carries it round. Where
|
||||||
|
* the source states none, or one a later measurement overturns, it is the one the body's ETL spec
|
||||||
|
* carries: Nereid's K2 light curve, Eris's lock to Dysnomia. Absent only for Hyperion, which
|
||||||
|
* tumbles — the view leaves it still rather than spinning it at an invented rate.
|
||||||
|
*/
|
||||||
|
rotationPeriodHours?: number;
|
||||||
|
obliquityDeg?: number;
|
||||||
|
/**
|
||||||
|
* Where the body's pole points and which way its prime meridian faces at any date, from the IAU
|
||||||
|
* WGCCRE 2015 report (Archinal et al. 2018) as NAIF's `pck00011.tpc` carries it, but that a locked
|
||||||
|
* moon's W, and its pole's terms that turn within 5 per cent of a multiple of its node's rate, turn
|
||||||
|
* at its drawn orbit's rates and Iapetus's pole goes round with its orbit's, so they keep their
|
||||||
|
* faces to their planets, and their poles round their orbits', over the clock's AD 1 to 3000 (see
|
||||||
|
* `lockedToOrbit` in the ETL). The Moon's and Phobos's, whose W has a quadratic, are the IAU's
|
||||||
|
* whole, and so are the terms Ariel's, Umbriel's, Titania's and Oberon's poles go round on, which
|
||||||
|
* turn at none of their nodes' multiples. Where present it alone sets how the body is drawn, and
|
||||||
|
* the ETL checks the period and obliquity above against it. Absent where the report gives none: Hyperion tumbles, and Nereid,
|
||||||
|
* Eris, Haumea and Makemake have no model.
|
||||||
|
*/
|
||||||
|
rotationalElements?: RotationalElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The IAU's rotational elements for one body: polynomials in time, plus periodic terms.
|
||||||
|
*
|
||||||
|
* The pole's right ascension and declination are in degrees in the ICRF, `[c0, c1, c2]` for
|
||||||
|
* `c0 + c1 T + c2 T²`, T in Julian centuries from J2000.0 TDB. The prime meridian W is the angle
|
||||||
|
* along the body's equator, anticlockwise seen from above that pole, from where the equator rises
|
||||||
|
* through the ICRF equator to the body's longitude 0, `c0 + c1 d + c2 d²` with d in days. A
|
||||||
|
* negative rate turns the body clockwise about the pole the IAU names: Venus, Uranus and its
|
||||||
|
* moons, Triton.
|
||||||
|
*/
|
||||||
|
export interface RotationalElements {
|
||||||
|
poleRaDeg: number[];
|
||||||
|
poleDecDeg: number[];
|
||||||
|
primeMeridianDeg: number[];
|
||||||
|
/**
|
||||||
|
* Each adds `ra sin θ` to the right ascension, `dec cos θ` to the declination and `pm sin θ` to
|
||||||
|
* W, θ being `angleDeg[0] + angleDeg[1] T + angleDeg[2] T²`. The smallest are left out; see
|
||||||
|
* `parsePckRotationalElements`.
|
||||||
|
*/
|
||||||
|
terms?: Array<{ angleDeg: number[]; ra: number; dec: number; pm: number }>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,26 @@ import { OrbitalElements } from './body.model';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A confirmed exoplanet from the NASA Exoplanet Archive (`Planetary Systems` TAP table),
|
* A confirmed exoplanet from the NASA Exoplanet Archive (`Planetary Systems` TAP table),
|
||||||
* cross-referenced to its host star in the HYG index where possible.
|
* cross-referenced to its host star in the star catalogue.
|
||||||
*/
|
*/
|
||||||
export interface ExoplanetRecord {
|
export interface ExoplanetRecord {
|
||||||
id: string;
|
id: string;
|
||||||
hostStarId: number | null; // null if the host star could not be cross-referenced to HYG
|
/**
|
||||||
|
* The host's star-catalogue id: a HYG or Gaia star it was matched to, or else a star the ETL
|
||||||
|
* added from the archive's own figures. Null only when the archive gives no position and
|
||||||
|
* distance to place one with.
|
||||||
|
*/
|
||||||
|
hostStarId: number | null;
|
||||||
hostStarName: string;
|
hostStarName: string;
|
||||||
name: string;
|
name: string;
|
||||||
radiusEarth?: number;
|
radiusEarth?: number;
|
||||||
massEarth?: number;
|
massEarth?: number;
|
||||||
discoveryYear?: number;
|
discoveryYear?: number;
|
||||||
|
/**
|
||||||
|
* True where the archive flags the planet as detected by imaging (`ima_flag`): photographed as a
|
||||||
|
* point of light beside its star, as HR 8799's four planets were. Absent for every other planet.
|
||||||
|
*/
|
||||||
|
imaged?: true;
|
||||||
/**
|
/**
|
||||||
* Measured orbital period in days (`pl_orbper`). Together with the semi-major axis this
|
* Measured orbital period in days (`pl_orbper`). Together with the semi-major axis this
|
||||||
* pins the host star's gravitational parameter exactly, so the planet can be propagated at
|
* pins the host star's gravitational parameter exactly, so the planet can be propagated at
|
||||||
@@ -20,6 +30,16 @@ export interface ExoplanetRecord {
|
|||||||
periodDays?: number;
|
periodDays?: number;
|
||||||
/** Host star mass in solar masses (`st_mass`); the fallback when no period is published. */
|
/** Host star mass in solar masses (`st_mass`); the fallback when no period is published. */
|
||||||
hostStarMassSolar?: number;
|
hostStarMassSolar?: number;
|
||||||
|
/**
|
||||||
|
* The host's radius in solar radii (`st_rad`), effective temperature in kelvin (`st_teff`) and
|
||||||
|
* luminosity in solar luminosities (10^`st_lum`), where the archive gives them: from the
|
||||||
|
* composite table (pscomppars) alone, since the default-row query does not ask for these three,
|
||||||
|
* and each column of it may come from a different reference — Proxima's 0.141 R☉ is one.
|
||||||
|
* Preferred to what `stellar.ts` would derive; see `starSurfaceOf`.
|
||||||
|
*/
|
||||||
|
hostStarRadiusSolar?: number;
|
||||||
|
hostStarTemperatureK?: number;
|
||||||
|
hostStarLuminositySolar?: number;
|
||||||
/**
|
/**
|
||||||
* The host star's own published astrometry (`ra`, `dec`, `sy_dist`, `sy_pmra`, `sy_pmdec`) —
|
* The host star's own published astrometry (`ra`, `dec`, `sy_dist`, `sy_pmra`, `sy_pmdec`) —
|
||||||
* everything the cross-reference above was resolved from.
|
* everything the cross-reference above was resolved from.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog, isDesignation } from './star-catalog';
|
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog, isDesignation } from './star-catalog';
|
||||||
import { StarRecord } from './star.model';
|
import { StarRecord } from './star.model';
|
||||||
|
import { formatDistance } from '../format/quantity';
|
||||||
|
|
||||||
const STARS: StarRecord[] = [
|
const STARS: StarRecord[] = [
|
||||||
{ id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 },
|
{ id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 },
|
||||||
@@ -97,6 +98,50 @@ describe('encodeStarCatalog / decodeStarCatalog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('the photometry and distance error columns', () => {
|
||||||
|
const MEASURED: StarRecord[] = [
|
||||||
|
{ id: 1, name: 'Sirius', x: 1, y: 0, z: 0, magnitude: -1.44, magnitudeBand: 'V', spectralType: 'A0m', colorIndex: 0.009, colorSystem: 'B-V', distanceError: 0.0036, source: 'hyg' },
|
||||||
|
{ id: 2, name: 'Gaia DR3 2', x: 0, y: 117, z: 0, magnitude: 11.2, magnitudeBand: 'G', spectralType: 'Unknown', colorIndex: 1.43, colorSystem: 'BP-RP', distanceError: 0.199, distanceFromGaia: true, source: 'gaia' },
|
||||||
|
// A HYG star at Gaia's distance, and one no survey gave a magnitude, a colour or an error.
|
||||||
|
{ id: 3, name: 'HD 3', x: 0, y: 0, z: 300, magnitude: 7, magnitudeBand: 'V', spectralType: 'K0', colorIndex: 1.0, colorSystem: 'B-V', distanceError: 1e-12, distanceFromGaia: true, source: 'hyg' },
|
||||||
|
{ id: 4, name: 'Gaia DR3 4', x: 5, y: 5, z: 0, magnitude: 12, spectralType: 'Unknown', colorIndex: null, distanceFromGaia: true, source: 'gaia' },
|
||||||
|
// A Hipparcos parallax smaller than its own error.
|
||||||
|
{ id: 5, name: 'HIP 5', x: 0, y: 200, z: 0, magnitude: 6, magnitudeBand: 'V', spectralType: 'B8', colorIndex: -0.1, colorSystem: 'B-V', distanceError: 1.4, source: 'hyg' },
|
||||||
|
// An archive host whose B−V is its temperature's.
|
||||||
|
{ id: 6, name: 'Kepler-445', x: 0, y: 0, z: 90, magnitude: 17.6, magnitudeBand: 'G', spectralType: 'M4', colorIndex: 1.66, colorSystem: 'B-V', colorFromTemperature: true, source: 'exoplanet-archive' }
|
||||||
|
];
|
||||||
|
const encoded = encodeStarCatalog(MEASURED);
|
||||||
|
const decoded = decodeStarCatalog(encoded.index, encoded.positions, encoded.meta);
|
||||||
|
|
||||||
|
it('carries the band, which colour the colour index is, and whose parallax the distance is', () => {
|
||||||
|
expect(decoded.map((star) => star.magnitudeBand)).toEqual(['V', 'G', 'V', undefined, 'V', 'G']);
|
||||||
|
expect(decoded.map((star) => star.colorSystem)).toEqual(['B-V', 'BP-RP', 'B-V', undefined, 'B-V', 'B-V']);
|
||||||
|
expect(decoded.map((star) => star.distanceFromGaia)).toEqual([false, true, true, true, false, false]);
|
||||||
|
expect(decoded.map((star) => star.colorFromTemperature)).toEqual([false, false, false, false, false, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a distance error to within a step at both ends of its range, and none as none', () => {
|
||||||
|
// A step is 0.05 % of distance at Sirius's 0.36 %, and 0.35 % at the 20 % Gaia's cut allows.
|
||||||
|
expect(Math.abs(decoded[0].distanceError! - 0.0036)).toBeLessThan(0.0003);
|
||||||
|
expect(Math.abs(decoded[1].distanceError! - 0.199)).toBeLessThan(0.002);
|
||||||
|
// Too small to round to a step — √(10⁻¹²) is 0.07 of one in 65 535 — but published, so not read
|
||||||
|
// back as unpublished. 10⁻⁶ was, at 255 steps; at 65 535 it rounds to 66 and never needed the floor.
|
||||||
|
expect(decoded[2].distanceError).toBeGreaterThan(0);
|
||||||
|
expect(decoded[3].distanceError).toBeUndefined();
|
||||||
|
// Past the parallax itself there is no upper bound on the distance, which is what 100 % says.
|
||||||
|
expect(decoded[4].distanceError).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an error close enough that the card prints the published one', () => {
|
||||||
|
// Rigel, 264.55 pc at van Leeuwen's 3.78 ± 0.34 mas: 23.8 pc, which one byte stored as 23.5.
|
||||||
|
const rigel: StarRecord = { id: 7, name: 'Rigel', x: 264.55, y: 0, z: 0, magnitude: 0.18, magnitudeBand: 'V', spectralType: 'B8Ia', colorIndex: -0.03, colorSystem: 'B-V', distanceError: 0.34 / 3.78, source: 'hyg' };
|
||||||
|
const packed = encodeStarCatalog([rigel]);
|
||||||
|
const [kept] = decodeStarCatalog(packed.index, packed.positions, packed.meta);
|
||||||
|
expect(formatDistance(264.55, kept.distanceError)).toBe('265 ± 24 pc');
|
||||||
|
expect(Math.abs(kept.distanceError! / rigel.distanceError! - 1)).toBeLessThan(1e-4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('star catalogue provenance and derived names', () => {
|
describe('star catalogue provenance and derived names', () => {
|
||||||
const MIXED: StarRecord[] = [
|
const MIXED: StarRecord[] = [
|
||||||
{ id: 5, name: 'Sirius', x: 1, y: 0, z: 0, magnitude: -1.4, spectralType: 'A0', colorIndex: 0.0, source: 'hyg' },
|
{ id: 5, name: 'Sirius', x: 1, y: 0, z: 0, magnitude: -1.4, spectralType: 'A0', colorIndex: 0.0, source: 'hyg' },
|
||||||
|
|||||||
@@ -26,13 +26,43 @@ export const STAR_POSITION_COMPONENTS = 3;
|
|||||||
export const BYTES_PER_STAR_POSITION = STAR_POSITION_COMPONENTS * Float32Array.BYTES_PER_ELEMENT;
|
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
|
* Columns in `stars-meta.bin`, in order: catalogue id, apparent magnitude, colour index, an
|
||||||
* index into the spectral-type dictionary. Stored column by column rather than record by record
|
* index into the spectral-type dictionary, the distance's relative error (see
|
||||||
* so each one is a single typed-array view over the buffer, with no per-record stride or
|
* {@link DISTANCE_ERROR_STEPS}), and what those were measured in (see {@link PHOTOMETRY}). Stored column by column
|
||||||
* alignment padding.
|
* 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 =
|
export const BYTES_PER_STAR_META =
|
||||||
Int32Array.BYTES_PER_ELEMENT + Float32Array.BYTES_PER_ELEMENT + Float32Array.BYTES_PER_ELEMENT + Uint16Array.BYTES_PER_ELEMENT;
|
Int32Array.BYTES_PER_ELEMENT +
|
||||||
|
Float32Array.BYTES_PER_ELEMENT +
|
||||||
|
Float32Array.BYTES_PER_ELEMENT +
|
||||||
|
Uint16Array.BYTES_PER_ELEMENT +
|
||||||
|
Uint16Array.BYTES_PER_ELEMENT +
|
||||||
|
Uint8Array.BYTES_PER_ELEMENT;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bits of the photometry column. The band takes two: none (a stand-in magnitude), V or G. None
|
||||||
|
* of it follows from the source, which records where the *position* came from: 62 097 stars
|
||||||
|
* Gaia places keep HYG's V and B−V, and the archive's stars in G have a B−V from their
|
||||||
|
* temperature. The next bit says whose parallax the distance is, which for a HYG star Gaia did
|
||||||
|
* not place can still be Gaia's, and the last whether the colour was read off a temperature.
|
||||||
|
*/
|
||||||
|
const PHOTOMETRY = { bandV: 1, bandG: 2, bandMask: 3, colorBpRp: 4, distanceFromGaia: 8, colorFromTemperature: 16 } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The distance error column holds the square root of the relative error, in 65 535ths, and 0 where
|
||||||
|
* none was published. The errors span three orders of magnitude — Gaia's are a median 0.3 % and
|
||||||
|
* at most 20 %, the cut its queries make, while a Hipparcos parallax the map keeps for a bright
|
||||||
|
* star can be as large as itself — and the square root keeps a step small at each end. Anything
|
||||||
|
* past 100 % is stored as that, where it no longer bounds the distance from above.
|
||||||
|
*
|
||||||
|
* In 255ths, one byte, a step was 0.35 % of the distance at a 20 % error, a few per cent of the
|
||||||
|
* error itself, and the card printed another error than the published one for 2 822 of the 53 209
|
||||||
|
* Gaia stars it prints one for; Rigel read ± 23 pc where van Leeuwen's 3.78 ± 0.34 mas gives 24.
|
||||||
|
* In two bytes, placed before the photometry byte so the column stays aligned for its view, 12 do,
|
||||||
|
* each on a rounding half.
|
||||||
|
*/
|
||||||
|
const DISTANCE_ERROR_STEPS = 65_535;
|
||||||
|
|
||||||
/** `stars-index.json`: everything that is a string, plus the count the columns are sized by. */
|
/** `stars-index.json`: everything that is a string, plus the count the columns are sized by. */
|
||||||
export interface StarCatalogIndex {
|
export interface StarCatalogIndex {
|
||||||
@@ -81,6 +111,8 @@ interface StarMetaColumns {
|
|||||||
magnitudes: Float32Array;
|
magnitudes: Float32Array;
|
||||||
colorIndices: Float32Array;
|
colorIndices: Float32Array;
|
||||||
spectralTypeIndices: Uint16Array;
|
spectralTypeIndices: Uint16Array;
|
||||||
|
photometry: Uint8Array;
|
||||||
|
distanceErrors: Uint16Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lays typed-array views over the meta buffer at the offsets the format defines. */
|
/** Lays typed-array views over the meta buffer at the offsets the format defines. */
|
||||||
@@ -93,8 +125,12 @@ function metaColumns(buffer: ArrayBuffer, count: number): StarMetaColumns {
|
|||||||
const colorIndices = new Float32Array(buffer, offset, count);
|
const colorIndices = new Float32Array(buffer, offset, count);
|
||||||
offset += count * Float32Array.BYTES_PER_ELEMENT;
|
offset += count * Float32Array.BYTES_PER_ELEMENT;
|
||||||
const spectralTypeIndices = new Uint16Array(buffer, offset, count);
|
const spectralTypeIndices = new Uint16Array(buffer, offset, count);
|
||||||
|
offset += count * Uint16Array.BYTES_PER_ELEMENT;
|
||||||
|
const distanceErrors = new Uint16Array(buffer, offset, count);
|
||||||
|
offset += count * Uint16Array.BYTES_PER_ELEMENT;
|
||||||
|
const photometry = new Uint8Array(buffer, offset, count);
|
||||||
|
|
||||||
return { ids, magnitudes, colorIndices, spectralTypeIndices };
|
return { ids, magnitudes, colorIndices, spectralTypeIndices, photometry, distanceErrors };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,6 +188,14 @@ export function encodeStarCatalog(stars: readonly StarRecord[]): {
|
|||||||
columns.magnitudes[index] = star.magnitude;
|
columns.magnitudes[index] = star.magnitude;
|
||||||
columns.colorIndices[index] = star.colorIndex ?? Number.NaN;
|
columns.colorIndices[index] = star.colorIndex ?? Number.NaN;
|
||||||
columns.spectralTypeIndices[index] = spectralTypeId;
|
columns.spectralTypeIndices[index] = spectralTypeId;
|
||||||
|
columns.photometry[index] =
|
||||||
|
(star.magnitudeBand === 'V' ? PHOTOMETRY.bandV : star.magnitudeBand === 'G' ? PHOTOMETRY.bandG : 0) |
|
||||||
|
(star.colorSystem === 'BP-RP' ? PHOTOMETRY.colorBpRp : 0) |
|
||||||
|
(star.distanceFromGaia ? PHOTOMETRY.distanceFromGaia : 0) |
|
||||||
|
(star.colorFromTemperature ? PHOTOMETRY.colorFromTemperature : 0);
|
||||||
|
// At least one step, so an error too small to round to one is not read back as none published.
|
||||||
|
columns.distanceErrors[index] =
|
||||||
|
star.distanceError === undefined ? 0 : Math.max(1, Math.round(Math.sqrt(Math.min(1, star.distanceError)) * DISTANCE_ERROR_STEPS));
|
||||||
});
|
});
|
||||||
|
|
||||||
// A per-star column is only worth writing when the stars actually differ.
|
// A per-star column is only worth writing when the stars actually differ.
|
||||||
@@ -186,6 +230,9 @@ export function decodeStarCatalog(index: StarCatalogIndex, positions: Float32Arr
|
|||||||
const id = columns.ids[i];
|
const id = columns.ids[i];
|
||||||
const sourceIndex = index.sourceIndices.length > 0 ? index.sourceIndices[i] : index.sources.length === 1 ? 0 : -1;
|
const sourceIndex = index.sourceIndices.length > 0 ? index.sourceIndices[i] : index.sources.length === 1 ? 0 : -1;
|
||||||
const source = index.sources[sourceIndex];
|
const source = index.sources[sourceIndex];
|
||||||
|
const photometry = columns.photometry[i];
|
||||||
|
const band = photometry & PHOTOMETRY.bandMask;
|
||||||
|
const distanceError = columns.distanceErrors[i];
|
||||||
|
|
||||||
stars[i] = {
|
stars[i] = {
|
||||||
id,
|
id,
|
||||||
@@ -196,6 +243,12 @@ export function decodeStarCatalog(index: StarCatalogIndex, positions: Float32Arr
|
|||||||
magnitude: columns.magnitudes[i],
|
magnitude: columns.magnitudes[i],
|
||||||
spectralType: index.spectralTypes[columns.spectralTypeIndices[i]],
|
spectralType: index.spectralTypes[columns.spectralTypeIndices[i]],
|
||||||
colorIndex: Number.isNaN(colorIndex) ? null : colorIndex,
|
colorIndex: Number.isNaN(colorIndex) ? null : colorIndex,
|
||||||
|
// Set on every record, if only to undefined, so that all of them have the one shape.
|
||||||
|
magnitudeBand: band === PHOTOMETRY.bandV ? 'V' : band === PHOTOMETRY.bandG ? 'G' : undefined,
|
||||||
|
colorSystem: Number.isNaN(colorIndex) ? undefined : photometry & PHOTOMETRY.colorBpRp ? 'BP-RP' : 'B-V',
|
||||||
|
distanceError: distanceError === 0 ? undefined : (distanceError / DISTANCE_ERROR_STEPS) ** 2,
|
||||||
|
distanceFromGaia: (photometry & PHOTOMETRY.distanceFromGaia) !== 0,
|
||||||
|
colorFromTemperature: (photometry & PHOTOMETRY.colorFromTemperature) !== 0,
|
||||||
...(source ? { source: source.id } : {})
|
...(source ? { source: source.id } : {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* A single star from the HYG (Hipparcos/Yale/Gliese) catalog, positioned relative to the
|
* A single star from Gaia DR3, HYG (Hipparcos/Yale/Gliese) or the NASA Exoplanet Archive — see
|
||||||
|
* `source` — positioned relative to the
|
||||||
* Sun in the galaxy-scale coordinate system (parsecs). The same positions are also packed
|
* Sun in the galaxy-scale coordinate system (parsecs). The same positions are also packed
|
||||||
* into a compact binary buffer (`stars.bin`, in index order) for fast bulk rendering; this
|
* into a compact binary buffer (`stars.bin`, in index order) for fast bulk rendering; this
|
||||||
* record format (`stars-index.json`) is used for search, labels, and lookups by id/name.
|
* record format (`stars-index.json`) is used for search, labels, and lookups by id/name.
|
||||||
@@ -13,19 +14,58 @@ export interface StarRecord {
|
|||||||
magnitude: number;
|
magnitude: number;
|
||||||
spectralType: string;
|
spectralType: string;
|
||||||
/**
|
/**
|
||||||
* B-V colour index, or `null` where the catalog has no photometry — about 10% of stars
|
* Colour index — B-V, or Gaia's BP-RP where `colorSystem` says so — or `null` where the
|
||||||
* within the distance cutoff. Deliberately nullable rather than defaulted: `0` is a real,
|
* catalog has no photometry. 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"
|
* 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
|
* silently mis-colours those stars. Consumers resolve the gap from `spectralType`; see
|
||||||
* `colorIndexToRgb`.
|
* `colorIndexToRgb`.
|
||||||
*/
|
*/
|
||||||
colorIndex: number | null;
|
colorIndex: number | null;
|
||||||
|
/**
|
||||||
|
* The band `magnitude` was measured in: Johnson V (HYG, and the archive where it has one) or
|
||||||
|
* Gaia's G, which for a red dwarf reads up to three magnitudes brighter than V. Absent where no
|
||||||
|
* survey measured the star and `magnitude` is the ETL's stand-in.
|
||||||
|
*/
|
||||||
|
magnitudeBand?: 'V' | 'G';
|
||||||
|
/**
|
||||||
|
* Which colour `colorIndex` is: Johnson B−V, or Gaia's BP−RP, which is larger for the same star
|
||||||
|
* — 0.82 against 0.65 for a G2 dwarf like the Sun, 3.35 against 1.83 for an M5 dwarf (Pecaut &
|
||||||
|
* Mamajek). Absent with it.
|
||||||
|
*/
|
||||||
|
colorSystem?: 'B-V' | 'BP-RP';
|
||||||
|
/**
|
||||||
|
* Whether `colorIndex` was read off the dwarf sequence at the star's effective temperature rather
|
||||||
|
* than measured: 54 archive-placed hosts with a temperature and no B or V magnitude. Three more,
|
||||||
|
* whose temperatures are outside the table, have no colour at all.
|
||||||
|
*/
|
||||||
|
colorFromTemperature?: boolean;
|
||||||
|
/**
|
||||||
|
* Relative uncertainty of the distance, σd/d: the relative error of the parallax it was
|
||||||
|
* inverted from, which to first order is the same — or, for a star the Exoplanet Archive places,
|
||||||
|
* the mean of the two one-sided errors it gives on the distance itself, which is often no
|
||||||
|
* parallax's. Absent where none was published.
|
||||||
|
*/
|
||||||
|
distanceError?: number;
|
||||||
|
/** Whether the distance is Gaia DR3's parallax, whichever catalogue describes the star. */
|
||||||
|
distanceFromGaia?: boolean;
|
||||||
/**
|
/**
|
||||||
* Which catalogue this star's position came from, once more than one contributes. Absent for a
|
* 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
|
* single-source build; see `star-merge.ts`, where overlapping catalogues are reconciled and
|
||||||
* the better-measured parallax wins.
|
* the better-measured parallax wins.
|
||||||
*/
|
*/
|
||||||
source?: string;
|
source?: string;
|
||||||
|
/**
|
||||||
|
* Proper motion in milliarcseconds a year, in right ascension (times cos δ) and declination,
|
||||||
|
* where the source measured one. Only the ETL sets these, for `star-merge.ts` to recognise two
|
||||||
|
* entries of one star whose positions disagree; the assets do not carry them.
|
||||||
|
*/
|
||||||
|
pmRaMasYr?: number;
|
||||||
|
pmDecMasYr?: number;
|
||||||
|
/**
|
||||||
|
* The Gaia DR3 source a Gaia entry is, kept once a HYG row has named it: what `foldByIdentity`
|
||||||
|
* looks up a star SIMBAD identifies by. Only the ETL sets it; the assets do not carry it.
|
||||||
|
*/
|
||||||
|
gaiaDesignation?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HYG id used for the Sun itself, so solar-system bodies can reference their host star. */
|
/** HYG id used for the Sun itself, so solar-system bodies can reference their host star. */
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import * as THREE from 'three/webgpu';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { tdbFromUtc } from '../astro/constants';
|
||||||
|
import { eclipticToEquatorial } from '../astro/coordinates';
|
||||||
|
import { meanElementsAt, positionAtEpoch } from '../astro/kepler';
|
||||||
|
import { BodyRecord } from '../models/body.model';
|
||||||
|
import { bodyOrientation, bodyPageView } from './body-orientation';
|
||||||
|
|
||||||
|
// Earth (the Earth-Moon barycentre's mean elements) and the Moon as bodies.json carries them.
|
||||||
|
const EARTH: BodyRecord = {
|
||||||
|
id: 'earth', systemStarId: 0, name: 'Earth', kind: 'planet', radiusKm: 6371, orbitSource: 'test',
|
||||||
|
orbit: {semiMajorAxisAu: 1.00000018, eccentricity: 0.01673163, inclinationDeg: -0.00054346, longitudeOfAscendingNodeDeg: -5.11260389, argumentOfPeriapsisDeg: 108.04266274, meanAnomalyAtEpochDeg: -2.4631431299999917, epochJd: 2451545},
|
||||||
|
rates: {meanMotionDegPerDay: 0.9856091187759068, longitudeOfAscendingNodeDegPerDay: -0.000006604751813826146, argumentOfPeriapsisDegPerDay: 0.000015309819575633124, semiMajorAxisAuPerDay: -8.213552361396303e-13, eccentricityPerDay: -1.002327173169062e-9, inclinationDegPerDay: -3.6609938398357287e-7},
|
||||||
|
rotationalElements: {poleRaDeg: [0, -0.641, 0], poleDecDeg: [90, -0.557, 0], primeMeridianDeg: [190.147, 360.9856235, 0]}
|
||||||
|
};
|
||||||
|
const MOON: BodyRecord = {
|
||||||
|
id: 'moon', systemStarId: 0, name: 'Moon', kind: 'moon', radiusKm: 1737.4, orbitSource: 'test', parentBodyId: 'earth',
|
||||||
|
orbit: {semiMajorAxisAu: 0.0025695552897999907, eccentricity: 0.0554, inclinationDeg: 5.16, longitudeOfAscendingNodeDeg: 125.08, argumentOfPeriapsisDeg: 318.15, meanAnomalyAtEpochDeg: 135.27, epochJd: 2451545},
|
||||||
|
rates: {meanMotionDegPerDay: 13.176358, longitudeOfAscendingNodeDegPerDay: -0.052990660396105185, argumentOfPeriapsisDegPerDay: 0.164353223839846},
|
||||||
|
rotationalElements: {poleRaDeg: [269.9949, 0.0031, 0], poleDecDeg: [66.5392, 0.013, 0], primeMeridianDeg: [38.3213, 13.17635815, -1.4e-12], terms: [{angleDeg: [125.045, -1935.5364525], ra: -3.8787, dec: 1.5419, pm: 3.561}, {angleDeg: [250.089, -3871.072905], ra: -0.1204, dec: 0.0239, pm: 0.1208}, {angleDeg: [260.008, 475263.3328725], ra: 0.07, dec: -0.0278, pm: -0.0642}, {angleDeg: [176.625, 487269.629985], ra: -0.0172, dec: 0.0068, pm: 0.0158}, {angleDeg: [357.529, 35999.0509575], ra: 0, dec: 0, pm: 0.0252}]}
|
||||||
|
};
|
||||||
|
const BODIES = [EARTH, MOON];
|
||||||
|
const JUNE_1_2025_NOON_UTC = 2460828.0;
|
||||||
|
/** The page's light, at (4, 3, 5): 38.7 degrees round from the camera's side. */
|
||||||
|
const SUN_AZIMUTH = Math.atan2(4, 5);
|
||||||
|
|
||||||
|
/** The point of the page's sphere, as east longitude and latitude on its map, that faces the Sun. */
|
||||||
|
function subSolarPoint(body: BodyRecord, jdUtc: number): { eastDeg: number; latDeg: number } {
|
||||||
|
const sphere = new THREE.Mesh(new THREE.SphereGeometry(1, 64, 32));
|
||||||
|
const sun = new THREE.Vector3();
|
||||||
|
expect(bodyPageView(body, BODIES, jdUtc, SUN_AZIMUTH, sphere.quaternion, sun)).toBe(true);
|
||||||
|
sphere.updateMatrixWorld();
|
||||||
|
const hit = new THREE.Raycaster(sun.clone().multiplyScalar(4), sun.clone().negate()).intersectObject(sphere)[0];
|
||||||
|
return { eastDeg: (hit.uv!.x - 0.5) * 360, latDeg: (hit.uv!.y - 0.5) * 180 };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('bodyPageView', () => {
|
||||||
|
it('lights the same face of Earth on its page: within 4 degrees of Greenwich at noon UTC, and where Horizons has it', () => {
|
||||||
|
expect(Math.abs(subSolarPoint(EARTH, JUNE_1_2025_NOON_UTC).eastDeg)).toBeLessThan(4);
|
||||||
|
// Horizons' sub-solar point from the Sun, 1.5795 E and 22.2604 N, is Earth as it was 8.43
|
||||||
|
// minutes before, when the light arriving then left the Sun; its latitude is geodetic, on the
|
||||||
|
// flattened Earth, where the sphere's is geocentric: 0.14 degrees apart at this latitude.
|
||||||
|
const horizons = subSolarPoint(EARTH, JUNE_1_2025_NOON_UTC - 8.43351424 / 1440);
|
||||||
|
const geodetic = (Math.atan(Math.tan((horizons.latDeg * Math.PI) / 180) / (1 - 1 / 298.257) ** 2) * 180) / Math.PI;
|
||||||
|
expect(Math.abs(horizons.eastDeg - 1.579501)).toBeLessThan(0.1);
|
||||||
|
expect(Math.abs(geodetic - 22.260426)).toBeLessThan(0.05);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lights Earth’s face where Horizons does at the far end of the clock too: AD 1000 and AD 1', () => {
|
||||||
|
// Horizons' sub-solar longitude from the Sun (observer quantity 14, TIME_TYPE=UT), Earth taken
|
||||||
|
// one light-time back: 1.0510 E on JD 2086455 and 1.5606 E on JD 1721600. The IAU's W, taken at
|
||||||
|
// UT + 69.184 s as it was, drew them 2.3 and 4.6 degrees west of that (2.0 and 4.3 at UT itself).
|
||||||
|
for (const [jdUt, lightMinutes, eastDeg] of [[2086455, 8.45437443, 1.05101], [1721600, 8.45020842, 1.560644]]) {
|
||||||
|
expect(Math.abs(subSolarPoint(EARTH, jdUt - lightMinutes / 1440).eastDeg - eastDeg)).toBeLessThan(0.15);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes a moon’s Sun from where it and its planet are: the Moon’s sub-solar point is Horizons’', () => {
|
||||||
|
const moon = subSolarPoint(MOON, JUNE_1_2025_NOON_UTC);
|
||||||
|
// 116.2859 E and 1.5030 N, seen from Earth's centre.
|
||||||
|
expect(Math.abs(moon.eastDeg - 116.285934)).toBeLessThan(0.1);
|
||||||
|
expect(Math.abs(moon.latDeg - 1.503004)).toBeLessThan(0.05);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the Sun where it stands at the same TDB instant the body is turned for, and Earth turned as the system view turns it', () => {
|
||||||
|
// Earth's own sphere, turned as the system view turns it (by UT, see `bodyOrientation`), and
|
||||||
|
// the Sun seen from Earth's mean place at the clock's date taken to TDB: the page must light that
|
||||||
|
// same point of its map, today and at AD 1000, when TT was 1 574 s past UT.
|
||||||
|
for (const jdUt of [JUNE_1_2025_NOON_UTC, 2086307.5]) {
|
||||||
|
const planet = new THREE.Quaternion();
|
||||||
|
const sun = new THREE.Vector3();
|
||||||
|
bodyPageView(EARTH, BODIES, jdUt, SUN_AZIMUTH, planet, sun);
|
||||||
|
const place = eclipticToEquatorial(positionAtEpoch(meanElementsAt(EARTH.orbit, EARTH.rates, tdbFromUtc(jdUt))));
|
||||||
|
const expected = new THREE.Vector3(-place.x, -place.y, -place.z).normalize().applyQuaternion(bodyOrientation(EARTH.rotationalElements!, jdUt, undefined, true).invert());
|
||||||
|
expect(sun.clone().applyQuaternion(planet.clone().invert()).angleTo(expected)).toBeLessThan(1e-9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the pole up and the Sun where the page’s light stands, turning the body under it', () => {
|
||||||
|
const planet = new THREE.Quaternion();
|
||||||
|
const sun = new THREE.Vector3();
|
||||||
|
for (const hours of [0, 6, 12]) {
|
||||||
|
bodyPageView(EARTH, BODIES, JUNE_1_2025_NOON_UTC + hours / 24, SUN_AZIMUTH, planet, sun);
|
||||||
|
expect(new THREE.Vector3(0, 1, 0).applyQuaternion(planet).angleTo(new THREE.Vector3(0, 1, 0))).toBeLessThan(1e-9);
|
||||||
|
expect(Math.atan2(sun.x, sun.z)).toBeCloseTo(SUN_AZIMUTH, 9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a body with no elements to the page, as it was', () => {
|
||||||
|
const planet = new THREE.Quaternion(0.1, 0.2, 0.3, 0.9).normalize();
|
||||||
|
const before = planet.clone();
|
||||||
|
const sun = new THREE.Vector3(4, 3, 5);
|
||||||
|
|
||||||
|
expect(bodyPageView({ ...EARTH, rotationalElements: undefined }, BODIES, JUNE_1_2025_NOON_UTC, SUN_AZIMUTH, planet, sun)).toBe(false);
|
||||||
|
expect(planet.equals(before)).toBe(true);
|
||||||
|
expect(sun.toArray()).toEqual([4, 3, 5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import * as THREE from 'three/webgpu';
|
||||||
|
|
||||||
|
import { tdbFromUtc } from '../astro/constants';
|
||||||
|
import { CartesianCoordinates, eclipticToEquatorial, laplacePlaneToEquatorial } from '../astro/coordinates';
|
||||||
|
import { meanElementsAt, positionAtEpoch } from '../astro/kepler';
|
||||||
|
import { orientationAt } from '../astro/rotational-elements';
|
||||||
|
import { BodyRecord, RotationalElements } from '../models/body.model';
|
||||||
|
|
||||||
|
const DEG_TO_RAD = Math.PI / 180;
|
||||||
|
const Y_AXIS = new THREE.Vector3(0, 1, 0);
|
||||||
|
const Z_AXIS = new THREE.Vector3(0, 0, 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a surface map sits on a sphere, settled once for every map the app wraps.
|
||||||
|
*
|
||||||
|
* `THREE.SphereGeometry` is built round +Y and runs its u coordinate eastward, anticlockwise seen
|
||||||
|
* from +Y, from a seam on -X: u = 0.5 faces +X and u = 0.75 faces -Z. Every photograph in
|
||||||
|
* `texture-catalog.ts` is an equirectangular map centred on longitude 0 with east to the right —
|
||||||
|
* Greenwich is in the middle of Earth's; on Mars's, Olympus Mons (226.2 E, which is -133.8) sits a
|
||||||
|
* little over a third of the width left of centre; on the Moon's, Mare Crisium (59 E) is right of
|
||||||
|
* centre and Mare Orientale (95 W) left of it; on Mercury's, the rayed crater Kuiper (31.5 W, 11 S)
|
||||||
|
* is just left of centre and below the equator; on Venus's, Maxwell Montes (65.2 N, 3.3 E) is the
|
||||||
|
* brightest spot, high and just right of centre — Solar System Scope ships that map turned half
|
||||||
|
* round, south up, and it is kept turned back. A map labelled in west longitude, as most planets'
|
||||||
|
* are, is still drawn with east to the right, as any map of a sphere seen from outside is; only its
|
||||||
|
* numbers run the other way. So longitude 0 is +X and 90 E is -Z, and a quarter turn about X
|
||||||
|
* carries that onto the IAU's body-fixed frame: pole +Z, prime meridian +X, 90 E +Y.
|
||||||
|
*
|
||||||
|
* The derived surfaces have no meridian of their own, and take the same convention.
|
||||||
|
*/
|
||||||
|
export const MAP_TO_BODY = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2);
|
||||||
|
|
||||||
|
const scratchMatrix = new THREE.Matrix4();
|
||||||
|
const scratchAxes = [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()];
|
||||||
|
const scratchTurn = new THREE.Quaternion();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets `target` to the rotation carrying a frame whose +Z is `pole` and whose +X is where its
|
||||||
|
* equator rises through the ICRF equator into the ICRF: the frame the IAU counts W in, and the
|
||||||
|
* one JPL refers a moon's Laplace plane to — so both go through {@link laplacePlaneToEquatorial}.
|
||||||
|
*/
|
||||||
|
export function poleFrame(pole: { raDeg: number; decDeg: number }, target = new THREE.Quaternion()): THREE.Quaternion {
|
||||||
|
const [x, y, z] = scratchAxes.map((axis, index) => {
|
||||||
|
const turned = laplacePlaneToEquatorial({ x: index === 0 ? 1 : 0, y: index === 1 ? 1 : 0, z: index === 2 ? 1 : 0 }, pole);
|
||||||
|
return axis.set(turned.x, turned.y, turned.z);
|
||||||
|
});
|
||||||
|
return target.setFromRotationMatrix(scratchMatrix.makeBasis(x, y, z));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets `target` to the rotation carrying a sphere, wrapped in its map as `SphereGeometry` wraps it,
|
||||||
|
* into the ICRF at the map's own clock: the map onto the body's frame, turned by W about the pole,
|
||||||
|
* and on to where the pole points.
|
||||||
|
*
|
||||||
|
* The clock is UT and the IAU's elements run on TDB, 69.184 s ahead today and 1 574 s at AD 1000;
|
||||||
|
* in 69 s Earth turns 0.29 degrees, Jupiter 0.70 and Phobos 0.90, so the date is taken to TDB here
|
||||||
|
* (see `tdbFromUtc`). Earth, `followsUt`, is the one exception: its turning is what UT counts, so
|
||||||
|
* it is turned by the IERS Earth Rotation Angle at the clock's date (IERS Conventions 2010, eq.
|
||||||
|
* 5.15), counted from the node its W starts at, 90 degrees past its pole's right ascension. The
|
||||||
|
* IAU's W for Earth, fitted to today, runs 6.3e-6 degrees a day slow of that once its pole's drift
|
||||||
|
* is counted: taken at UT + 69.184 s, as it was, it left Earth's lit face 2.3 degrees off Horizons at
|
||||||
|
* AD 1000 and 4.5 to 4.6 over AD 1 (2.0, and 4.2 to 4.3, at UT itself). Taken at TDB, it would
|
||||||
|
* have turned ΔT further, 44 degrees at AD 1.
|
||||||
|
*/
|
||||||
|
export function bodyOrientation(elements: RotationalElements, jdUtc: number, target = new THREE.Quaternion(), followsUt = false): THREE.Quaternion {
|
||||||
|
const { poleRaDeg, poleDecDeg, primeMeridianDeg } = orientationAt(elements, tdbFromUtc(jdUtc));
|
||||||
|
const turnDeg = followsUt ? 360 * (0.779057273264 + 1.00273781191135448 * (jdUtc - 2451545)) - 90 - poleRaDeg : primeMeridianDeg;
|
||||||
|
return poleFrame({ raDeg: poleRaDeg, decDeg: poleDecDeg }, target)
|
||||||
|
.multiply(scratchTurn.setFromAxisAngle(Z_AXIS, turnDeg * DEG_TO_RAD))
|
||||||
|
.multiply(MAP_TO_BODY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a body is from the Sun at a date, in the ICRF, AU: a moon's planet's place plus its own. */
|
||||||
|
function heliocentricPosition(body: BodyRecord, bodies: readonly BodyRecord[], jdUtc: number): CartesianCoordinates {
|
||||||
|
const jdTdb = tdbFromUtc(jdUtc);
|
||||||
|
const own = positionAtEpoch(meanElementsAt(body.orbit, body.rates, jdTdb));
|
||||||
|
const parent = body.parentBodyId ? bodies.find((candidate) => candidate.id === body.parentBodyId) : undefined;
|
||||||
|
if (!parent) {
|
||||||
|
return eclipticToEquatorial(own);
|
||||||
|
}
|
||||||
|
const offset = body.laplacePole ? laplacePlaneToEquatorial(own, body.laplacePole) : eclipticToEquatorial(own);
|
||||||
|
const centre = eclipticToEquatorial(positionAtEpoch(meanElementsAt(parent.orbit, parent.rates, jdTdb)));
|
||||||
|
return { x: centre.x + offset.x, y: centre.y + offset.y, z: centre.z + offset.z };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scratchPage = new THREE.Quaternion();
|
||||||
|
const scratchPageTurn = new THREE.Quaternion();
|
||||||
|
const scratchBody = new THREE.Quaternion();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the body page shows a body the IAU gives elements for: pole up, as the page has always
|
||||||
|
* drawn it, turned as it really is at the map's date against a Sun held at `sunAzimuthRad` round
|
||||||
|
* that pole — where the page's light has always stood, so the camera still opens on the day side.
|
||||||
|
* The Sun's height above the equator is its real one, and the face it lights is the real one:
|
||||||
|
* seen from the body, the Sun sits over the same point of its map as in the system view. What the
|
||||||
|
* page gives up is the stars, which do not turn with the body.
|
||||||
|
*
|
||||||
|
* Sets `planet` to the sphere's rotation and `sun` to the unit direction of the Sun in the page's
|
||||||
|
* frame. Returns false, touching neither, for a body without elements.
|
||||||
|
*/
|
||||||
|
export function bodyPageView(body: BodyRecord, bodies: readonly BodyRecord[], jdUtc: number, sunAzimuthRad: number, planet: THREE.Quaternion, sun: THREE.Vector3): boolean {
|
||||||
|
const elements = body.rotationalElements;
|
||||||
|
if (!elements) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { poleRaDeg, poleDecDeg } = orientationAt(elements, tdbFromUtc(jdUtc));
|
||||||
|
// From the ICRF into the body's frame with its pole on +Y, before the turn about that pole.
|
||||||
|
const toPage = poleFrame({ raDeg: poleRaDeg, decDeg: poleDecDeg }, scratchPage).multiply(MAP_TO_BODY).invert();
|
||||||
|
const position = heliocentricPosition(body, bodies, jdUtc);
|
||||||
|
sun.set(-position.x, -position.y, -position.z).normalize().applyQuaternion(toPage);
|
||||||
|
const turn = scratchPageTurn.setFromAxisAngle(Y_AXIS, sunAzimuthRad - Math.atan2(sun.x, sun.z));
|
||||||
|
sun.applyQuaternion(turn);
|
||||||
|
planet.copy(turn).multiply(toPage).multiply(bodyOrientation(elements, jdUtc, scratchBody, body.id === 'earth'));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
// Node, for the one test that reads a map's bytes from disk.
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import * as THREE from 'three/webgpu';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { bodyTexturePath, saturnRing } from './texture-catalog';
|
||||||
|
|
||||||
|
describe('bodyTexturePath', () => {
|
||||||
|
it('wraps the moons and dwarf planets that have a mission mosaic in it', () => {
|
||||||
|
const mapped = ['phobos', 'deimos', 'io', 'europa', 'ganymede', 'callisto', 'mimas', 'enceladus', 'tethys', 'dione', 'rhea', 'titan', 'iapetus', 'phoebe', 'triton', 'ceres', 'pluto', 'charon'];
|
||||||
|
for (const id of mapped) {
|
||||||
|
expect(bodyTexturePath(id)).toBe(`assets/textures/bodies/${id}.jpg`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps Venus in the map turned north up, the one checked for Maxwell Montes', () => {
|
||||||
|
// As the Solar System Scope pack ships it, venus.jpg is the Magellan map turned half round:
|
||||||
|
// Maxwell Montes, 65.2 N 3.3 E in the IAU Gazetteer, was its brightest point at 63 S 9 W. The
|
||||||
|
// file turned back puts it at 63.7 N 8.3 E; see assets/textures/README.md. No decoder runs
|
||||||
|
// here, so the check is pinned to those bytes.
|
||||||
|
expect(bodyTexturePath('venus')).toBe('assets/textures/bodies/venus.jpg');
|
||||||
|
const bytes = readFileSync(`${process.cwd()}/src/assets/textures/bodies/venus.jpg`);
|
||||||
|
expect(createHash('sha256').update(bytes).digest('hex')).toBe('4528b8e9a3cf880d80e8a5321a3001e8792bc18f266c06f3456a6bd8475e021d');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the bodies with no map it could check to their derived surface', () => {
|
||||||
|
for (const id of ['miranda', 'ariel', 'umbriel', 'titania', 'oberon', 'hyperion', 'eris']) {
|
||||||
|
expect(bodyTexturePath(id)).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saturnRing', () => {
|
||||||
|
const SATURN_RADIUS_KM = 58232;
|
||||||
|
|
||||||
|
/** Each vertex's distance from the centre, in km, beside the texture coordinate it samples. */
|
||||||
|
function radiiAndU(ring: THREE.Mesh, kmPerUnit: number): Array<{ km: number; u: number; y: number }> {
|
||||||
|
const position = ring.geometry.attributes['position'];
|
||||||
|
const uv = ring.geometry.attributes['uv'];
|
||||||
|
const vertex = new THREE.Vector3();
|
||||||
|
return Array.from({ length: position.count }, (_, i) => {
|
||||||
|
vertex.fromBufferAttribute(position, i);
|
||||||
|
return { km: vertex.length() * kmPerUnit, u: uv.getX(i), y: vertex.y };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reaches from 69 400 to 141 000 km, drawn against the planet at whatever size it is drawn', () => {
|
||||||
|
for (const drawnRadius of [1, 3.9e-4]) {
|
||||||
|
const radii = radiiAndU(saturnRing(SATURN_RADIUS_KM, drawnRadius), SATURN_RADIUS_KM / drawnRadius).map(({ km }) => km);
|
||||||
|
expect(Math.min(...radii)).toBeCloseTo(69400, 0);
|
||||||
|
expect(Math.max(...radii)).toBeCloseTo(141000, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is lit, and seen from either face', () => {
|
||||||
|
// An unlit ring shows no day and night; one drawn from its front face alone vanishes when Earth
|
||||||
|
// is on its south side, as on 24 September 2026.
|
||||||
|
const material = saturnRing(SATURN_RADIUS_KM, 1).material as THREE.Material;
|
||||||
|
expect(material).toBeInstanceOf(THREE.MeshStandardMaterial);
|
||||||
|
expect(material.side).toBe(THREE.DoubleSide);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lies in the equator of a sphere built round +Y', () => {
|
||||||
|
for (const { y } of radiiAndU(saturnRing(SATURN_RADIUS_KM, 1), SATURN_RADIUS_KM)) {
|
||||||
|
expect(Math.abs(y)).toBeLessThan(1e-12);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('samples the strip outwards, so its B ring starts at 92 000 km and its A ring ends at 136 775', () => {
|
||||||
|
// Where the strip's alpha jumps: 404.5 and 1 204 of its 1 280 px.
|
||||||
|
const vertices = radiiAndU(saturnRing(SATURN_RADIUS_KM, 1), SATURN_RADIUS_KM);
|
||||||
|
const inner = vertices.reduce((a, b) => (b.km < a.km ? b : a));
|
||||||
|
const outer = vertices.reduce((a, b) => (b.km > a.km ? b : a));
|
||||||
|
const uAt = (km: number): number => inner.u + ((km - inner.km) / (outer.km - inner.km)) * (outer.u - inner.u);
|
||||||
|
expect(Math.abs(uAt(92000) * 1280 - 404.5)).toBeLessThan(1.5);
|
||||||
|
expect(Math.abs(uAt(136775) * 1280 - 1204)).toBeLessThan(1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,18 +4,25 @@ import * as THREE from 'three/webgpu';
|
|||||||
* Real NASA/ESA/USGS photography baked into `src/assets/textures/bodies/` at build time,
|
* Real NASA/ESA/USGS photography baked into `src/assets/textures/bodies/` at build time,
|
||||||
* keyed by the same ids used in `bodies.json`.
|
* keyed by the same ids used in `bodies.json`.
|
||||||
*
|
*
|
||||||
* This map is the whole of what has actually been photographed. Everything else — every
|
* Only surface *maps* belong here: equirectangular images, twice as wide as tall, that wrap a
|
||||||
* exoplanet, since not one has ever been imaged, and the moons no probe returned a usable map
|
* sphere. Everything else — every exoplanet, since the few imaged were seen only as points of
|
||||||
* of — falls through to `procedural-planet-texture.ts`, which derives a surface from the body's
|
* light, and every moon
|
||||||
* own measured size, mass, orbit and host star instead.
|
* or dwarf planet with no such map in the repository — falls through to
|
||||||
|
* `procedural-planet-texture.ts`, which derives a surface from the body's own measured size,
|
||||||
|
* mass, orbit and host star instead.
|
||||||
*
|
*
|
||||||
* Provenance (all public domain NASA/JPL or CC BY 4.0 Solar System Scope, via Wikimedia
|
* Io, Pluto, Titan and Deimos used to be listed with square photographs of them: pictures of a
|
||||||
* Commons — see each file's Commons page for the original credit line):
|
* lit disc against black sky, not maps, which wrapped round a sphere put black sky on a fifth to a
|
||||||
* mercury/venus/earth/mars/saturn/uranus/neptune/moon/sun/saturn-ring/skybox — Solar System
|
* third of the surface. All four are now global mosaics like the other moons'.
|
||||||
* Scope texture pack (CC BY 4.0); jupiter — Solar System Scope 8k pack (CC BY 4.0); pluto —
|
*
|
||||||
* NASA/JHUAPL/SwRI New Horizons true-color mosaic; deimos — NASA/JPL/University of Arizona
|
* Provenance (CC BY 4.0 Solar System Scope, via Wikimedia Commons — see each file's Commons page
|
||||||
* MRO HiRISE; io — NASA/JPL Galileo highest-resolution true-color mosaic; titan — NASA/JPL
|
* for the original credit line): mercury/venus/earth/mars/saturn/uranus/neptune/moon/sun/
|
||||||
* Cassini true-color view.
|
* saturn-ring/skybox — Solar System Scope texture pack; jupiter — Solar System Scope 8k pack.
|
||||||
|
*
|
||||||
|
* The moons', Ceres's and Pluto's are public-domain mission mosaics from USGS Astrogeology and the
|
||||||
|
* PDS, each put in the same frame — longitude 0 in the middle, east to the right — and each
|
||||||
|
* measured, in `assets/textures/README.md`. Where a probe saw only part of a body (Pluto, Charon,
|
||||||
|
* Triton, Phoebe, the Galilean poles), the rest is a flat grey, never invented terrain.
|
||||||
*/
|
*/
|
||||||
const BODY_TEXTURE_PATHS: Record<string, string> = {
|
const BODY_TEXTURE_PATHS: Record<string, string> = {
|
||||||
mercury: 'assets/textures/bodies/mercury.jpg',
|
mercury: 'assets/textures/bodies/mercury.jpg',
|
||||||
@@ -26,14 +33,37 @@ const BODY_TEXTURE_PATHS: Record<string, string> = {
|
|||||||
saturn: 'assets/textures/bodies/saturn.jpg',
|
saturn: 'assets/textures/bodies/saturn.jpg',
|
||||||
uranus: 'assets/textures/bodies/uranus.jpg',
|
uranus: 'assets/textures/bodies/uranus.jpg',
|
||||||
neptune: 'assets/textures/bodies/neptune.jpg',
|
neptune: 'assets/textures/bodies/neptune.jpg',
|
||||||
pluto: 'assets/textures/bodies/pluto.jpg',
|
|
||||||
moon: 'assets/textures/bodies/moon.jpg',
|
moon: 'assets/textures/bodies/moon.jpg',
|
||||||
|
phobos: 'assets/textures/bodies/phobos.jpg',
|
||||||
deimos: 'assets/textures/bodies/deimos.jpg',
|
deimos: 'assets/textures/bodies/deimos.jpg',
|
||||||
io: 'assets/textures/bodies/io.jpg',
|
io: 'assets/textures/bodies/io.jpg',
|
||||||
titan: 'assets/textures/bodies/titan.jpg'
|
europa: 'assets/textures/bodies/europa.jpg',
|
||||||
|
ganymede: 'assets/textures/bodies/ganymede.jpg',
|
||||||
|
callisto: 'assets/textures/bodies/callisto.jpg',
|
||||||
|
mimas: 'assets/textures/bodies/mimas.jpg',
|
||||||
|
enceladus: 'assets/textures/bodies/enceladus.jpg',
|
||||||
|
tethys: 'assets/textures/bodies/tethys.jpg',
|
||||||
|
dione: 'assets/textures/bodies/dione.jpg',
|
||||||
|
rhea: 'assets/textures/bodies/rhea.jpg',
|
||||||
|
titan: 'assets/textures/bodies/titan.jpg',
|
||||||
|
iapetus: 'assets/textures/bodies/iapetus.jpg',
|
||||||
|
phoebe: 'assets/textures/bodies/phoebe.jpg',
|
||||||
|
triton: 'assets/textures/bodies/triton.jpg',
|
||||||
|
ceres: 'assets/textures/bodies/ceres.jpg',
|
||||||
|
pluto: 'assets/textures/bodies/pluto.jpg',
|
||||||
|
charon: 'assets/textures/bodies/charon.jpg'
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The Sun isn't a `BodyRecord` (it's the system's star marker), so it's looked up separately. */
|
/**
|
||||||
|
* The Sun's surface, which every star's disc in the system view is drawn with, tinted to its own
|
||||||
|
* colour. Baked from the pack's 2048 by 1024 map (822 427 bytes) to 1024 by 512 in grey (31 306):
|
||||||
|
* the Sun reaches 216 px across at its closest approach on a 1080-line screen, and a sphere shows
|
||||||
|
* π times its diameter of the map round its equator, so this covers it to a 1440-line one. The
|
||||||
|
* tint supplies the colour. The map's brightness varied by 56 % rms, a mottled rock rather than a
|
||||||
|
* star; it is rescaled to 14 % rms about the display's white, of the order of the Sun's own
|
||||||
|
* granulation contrast, and the brighter half clipped there as in a photograph exposed for the
|
||||||
|
* disc, which leaves 6 %. Not a `BodyRecord`, so it is looked up separately.
|
||||||
|
*/
|
||||||
export const SUN_TEXTURE_PATH = 'assets/textures/bodies/sun.jpg';
|
export const SUN_TEXTURE_PATH = 'assets/textures/bodies/sun.jpg';
|
||||||
export const SATURN_RING_TEXTURE_PATH = 'assets/textures/bodies/saturn_ring.png';
|
export const SATURN_RING_TEXTURE_PATH = 'assets/textures/bodies/saturn_ring.png';
|
||||||
export const MILKY_WAY_SKYBOX_PATH = 'assets/textures/skybox/milkyway.jpg';
|
export const MILKY_WAY_SKYBOX_PATH = 'assets/textures/skybox/milkyway.jpg';
|
||||||
@@ -58,6 +88,54 @@ export function atmosphereColorFor(id: string): THREE.ColorRepresentation | unde
|
|||||||
return ATMOSPHERE_BY_ID[id];
|
return ATMOSPHERE_BY_ID[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The radii, in km from Saturn's centre, that `saturn_ring.png`'s left and right edges stand for.
|
||||||
|
*
|
||||||
|
* The strip runs straight out from its left edge to its right, and read off its alpha the ring
|
||||||
|
* edges fall where one scale puts them: the C ring's inner edge (74 490 km) at 91 of its 1 280 px,
|
||||||
|
* the B ring's inner edge (92 000) at 404.5 and outer (117 580) at 860, the A ring's outer edge
|
||||||
|
* (136 775) at 1 204 and the F ring (140 180) at 1 267.5 — all within 1.8 px of 55.9 km a pixel.
|
||||||
|
* The one miss is the Cassini Division's outer edge (122 170), which the strip draws 30 px (1 700
|
||||||
|
* km) too far in. The edges are not the 74 500 and 140 220 km of the C ring and the F ring: sized to
|
||||||
|
* those, the B ring's inner edge would sit 3 300 km out.
|
||||||
|
*/
|
||||||
|
export const SATURN_RING_INNER_KM = 69_400;
|
||||||
|
export const SATURN_RING_OUTER_KM = 141_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saturn's rings, flat in the equator of a sphere built round +Y — its XZ plane — and sized
|
||||||
|
* against the planet as drawn: `drawnRadius` for Saturn's `planetRadiusKm`, so the rings keep their
|
||||||
|
* true proportion to the planet wherever it is drawn and however it is scaled.
|
||||||
|
*
|
||||||
|
* `RingGeometry`'s own UVs wrap round the angle, so u is set to the distance from the centre instead,
|
||||||
|
* which is the way the strip runs. Lit, from both faces: the face turned to the Sun is lit by the
|
||||||
|
* height of the Sun above the ring plane, and the other falls dark. Nothing in the app casts a
|
||||||
|
* shadow, so neither the planet on the rings nor the rings on the planet do.
|
||||||
|
*/
|
||||||
|
export function saturnRing(planetRadiusKm: number, drawnRadius: number): THREE.Mesh {
|
||||||
|
const unitsPerKm = drawnRadius / planetRadiusKm;
|
||||||
|
const inner = SATURN_RING_INNER_KM * unitsPerKm;
|
||||||
|
const outer = SATURN_RING_OUTER_KM * unitsPerKm;
|
||||||
|
const geometry = new THREE.RingGeometry(inner, outer, 128, 1).rotateX(-Math.PI / 2);
|
||||||
|
const position = geometry.attributes['position'];
|
||||||
|
const uv = geometry.attributes['uv'];
|
||||||
|
const vertex = new THREE.Vector3();
|
||||||
|
for (let i = 0; i < position.count; i++) {
|
||||||
|
vertex.fromBufferAttribute(position, i);
|
||||||
|
uv.setXY(i, THREE.MathUtils.clamp((vertex.length() - inner) / (outer - inner), 0, 1), 1);
|
||||||
|
}
|
||||||
|
// The strip's own alpha is the rings' opacity: dense in the B ring, thin in the C ring.
|
||||||
|
const material = new THREE.MeshStandardMaterial({
|
||||||
|
map: loadCachedTexture(SATURN_RING_TEXTURE_PATH),
|
||||||
|
transparent: true,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
depthWrite: false,
|
||||||
|
roughness: 1,
|
||||||
|
metalness: 0
|
||||||
|
});
|
||||||
|
return new THREE.Mesh(geometry, material);
|
||||||
|
}
|
||||||
|
|
||||||
const textureLoader = new THREE.TextureLoader();
|
const textureLoader = new THREE.TextureLoader();
|
||||||
const loadedTextures = new Map<string, THREE.Texture>();
|
const loadedTextures = new Map<string, THREE.Texture>();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { TimeStore } from './time.store';
|
||||||
|
|
||||||
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
const START = new Date('2026-09-22T12:00:00Z');
|
||||||
|
|
||||||
|
describe('TimeStore', () => {
|
||||||
|
let time: TimeStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(START);
|
||||||
|
time = new TimeStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the world’s own time until asked otherwise', () => {
|
||||||
|
const opened = time.julianDate();
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
|
||||||
|
expect(time.julianDate() - opened).toBeCloseTo(10_000 / MS_PER_DAY, 9);
|
||||||
|
expect(time.date().toISOString()).toBe('2026-09-22T12:00:10.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the sky faster without moving where it starts from', () => {
|
||||||
|
const opened = time.julianDate();
|
||||||
|
time.setRate(3600);
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
|
||||||
|
// A second of watching is an hour of sky, and the date did not jump when the rate changed.
|
||||||
|
expect(time.julianDate() - opened).toBeCloseTo(1 / 24, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries on from where it had got to when the rate changes again', () => {
|
||||||
|
time.setRate(86_400);
|
||||||
|
vi.advanceTimersByTime(2000); // two days of sky
|
||||||
|
const afterTwoDays = time.julianDate();
|
||||||
|
|
||||||
|
time.setRate(1);
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
|
||||||
|
// Slowing down keeps the two days: it does not rewind to the wall clock.
|
||||||
|
expect(time.julianDate() - afterTwoDays).toBeCloseTo(1000 / MS_PER_DAY, 9);
|
||||||
|
expect(time.julianDate() - afterTwoDays).toBeLessThan(1 / 24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the date backwards at a negative rate', () => {
|
||||||
|
time.setRate(-86_400);
|
||||||
|
const before = time.julianDate();
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
|
||||||
|
expect(time.julianDate() - before).toBeCloseTo(-1, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('knows the map is away from now even once it is back at real time', () => {
|
||||||
|
expect(time.atNow()).toBe(true);
|
||||||
|
time.setRate(2_629_800);
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
time.setRate(1);
|
||||||
|
|
||||||
|
// Real time, months ahead: the rate says nothing about where the clock stands.
|
||||||
|
expect(time.atNow()).toBe(false);
|
||||||
|
time.reset();
|
||||||
|
expect(time.atNow()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('comes back to now, at real time', () => {
|
||||||
|
time.setRate(2_629_800);
|
||||||
|
vi.advanceTimersByTime(5000); // months away
|
||||||
|
expect(time.date().getUTCFullYear()).toBeGreaterThan(START.getUTCFullYear());
|
||||||
|
|
||||||
|
time.reset();
|
||||||
|
|
||||||
|
expect(time.rate()).toBe(1);
|
||||||
|
// Now, not the moment the store was built: five seconds of wall clock have passed.
|
||||||
|
expect(time.date().toISOString()).toBe(new Date(START.getTime() + 5000).toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jumps to a date and carries on from it at the rate it was running at', () => {
|
||||||
|
vi.advanceTimersByTime(5000); // five seconds before the jump, which it must not add on
|
||||||
|
expect(time.setDate(new Date('2020-12-21T18:00Z'))).toBe(true);
|
||||||
|
// Away from now although still at real time: the rate never changed, the date did.
|
||||||
|
expect(time.atNow()).toBe(false);
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(time.date().toISOString()).toBe('2020-12-21T18:00:01.000Z');
|
||||||
|
|
||||||
|
time.setRate(3600);
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(time.date().toISOString()).toBe('2020-12-21T19:00:01.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops a running clock at either end of the window, at real time turned back into it', () => {
|
||||||
|
time.setDate(new Date('0001-01-10T00:00Z'));
|
||||||
|
time.setRate(-2_629_800); // a month a second, backwards
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
expect(time.date().toISOString()).toBe('0001-01-01T00:00:00.000Z');
|
||||||
|
expect(time.rate()).toBe(1);
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(time.date().toISOString()).toBe('0001-01-01T00:00:01.000Z');
|
||||||
|
|
||||||
|
time.setDate(new Date('2999-12-01T00:00Z'));
|
||||||
|
time.setRate(2_629_800);
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
expect(time.date().toISOString()).toBe('3000-01-01T00:00:00.000Z');
|
||||||
|
expect(time.rate()).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a date the planets’ elements were never fitted for, and stays where it was', () => {
|
||||||
|
const before = time.date().toISOString();
|
||||||
|
|
||||||
|
expect(time.setDate(new Date('3000-01-01T00:01Z'))).toBe(false);
|
||||||
|
expect(time.setDate(new Date(Date.UTC(-100, 0, 1)))).toBe(false); // 101 BC
|
||||||
|
expect(time.setDate(new Date('not a date'))).toBe(false);
|
||||||
|
expect(time.date().toISOString()).toBe(before);
|
||||||
|
expect(time.atNow()).toBe(true);
|
||||||
|
|
||||||
|
// Both ends are in: the first day a date input can hold, and the end of Standish's fit.
|
||||||
|
expect(time.setDate(new Date('0001-01-01T00:00Z'))).toBe(true);
|
||||||
|
expect(time.date().toISOString()).toBe('0001-01-01T00:00:00.000Z');
|
||||||
|
expect(time.setDate(new Date('3000-01-01T00:00Z'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
import { dateToJulianDate } from '../astro/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How fast the map's clock runs, in seconds of sky per second of wall clock.
|
||||||
|
*
|
||||||
|
* The map is built on propagated orbits and published rotation periods, both of which are
|
||||||
|
* functions of a date — so the only thing standing between it and a working orrery is the number
|
||||||
|
* on this list. At real time nothing appears to move: Earth turns 15 degrees an hour and takes a
|
||||||
|
* year to go round, and a reader watching for a minute sees a still picture.
|
||||||
|
*
|
||||||
|
* An hour a second is the rate at which rotation reads — Jupiter turns once every ten seconds of
|
||||||
|
* watching. A day a second is the rate at which the inner planets read. A month a second carries
|
||||||
|
* the outer ones, at which point the inner four are a blur, which is honest: that is what the
|
||||||
|
* solar system does.
|
||||||
|
*/
|
||||||
|
export const TIME_RATES = [
|
||||||
|
{ label: 'Real time', secondsPerSecond: 1 },
|
||||||
|
{ label: '1 h/s', secondsPerSecond: 3600 },
|
||||||
|
{ label: '1 d/s', secondsPerSecond: 86_400 },
|
||||||
|
{ label: '1 mo/s', secondsPerSecond: 2_629_800 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
const JULIAN_DATE_AT_EPOCH = 2440587.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dates the clock can be set to, as `datetime-local` values read as UTC.
|
||||||
|
*
|
||||||
|
* The end is where Standish's Table 2, the mean elements that carry the planets, stops being
|
||||||
|
* fitted: it covers 3000 BC to AD 3000, and every planet was within 0.29 degrees of Horizons at
|
||||||
|
* each date measured out to 3000. The start is not the fit's but the date input's, which cannot
|
||||||
|
* go before 0001-01-01. Both are proleptic Gregorian, as a `Date` is, so before 1582 they part from
|
||||||
|
* the Julian-calendar dates history gives: two days behind them at AD 1, level from AD 200 to 300,
|
||||||
|
* ten days ahead by 1582. Pluto, on Standish's elements too, holds with them; the moons and the four
|
||||||
|
* dwarf planets from the SBDB hold for far less of it: each of their cards says how
|
||||||
|
* far its orbit strays from Horizons from 1950 to 2100 (Ceres 7.1 degrees there, 11.6 by 2200 and
|
||||||
|
* 39 by 1600).
|
||||||
|
*/
|
||||||
|
export const CLOCK_WINDOW = { min: '0001-01-01T00:00', max: '3000-01-01T00:00' } as const;
|
||||||
|
const WINDOW_MS = {
|
||||||
|
min: Date.parse(`${CLOCK_WINDOW.min}Z`),
|
||||||
|
max: Date.parse(`${CLOCK_WINDOW.max}Z`),
|
||||||
|
};
|
||||||
|
const WINDOW_JD = {
|
||||||
|
min: WINDOW_MS.min / MS_PER_DAY + JULIAN_DATE_AT_EPOCH,
|
||||||
|
max: WINDOW_MS.max / MS_PER_DAY + JULIAN_DATE_AT_EPOCH,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date the map is drawn for.
|
||||||
|
*
|
||||||
|
* Read every frame rather than held in a signal: it changes continuously, and a signal that
|
||||||
|
* changed sixty times a second would ask the whole HUD to re-render for a number nothing is
|
||||||
|
* watching. The rate *is* a signal, since a reader sets it and the controls read it back.
|
||||||
|
*
|
||||||
|
* Changing the rate re-anchors instead of rewinding: the date carries on from where it had got
|
||||||
|
* to, so speeding up and slowing down never jumps the sky. A negative rate runs the same clock
|
||||||
|
* backwards: every orbit and every rotation is a function of the date, so going back is the same
|
||||||
|
* sum with the sign turned.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class TimeStore {
|
||||||
|
readonly rate = signal<number>(TIME_RATES[0].secondsPerSecond);
|
||||||
|
/**
|
||||||
|
* Whether the map is drawn for the present. Not the same as a rate of one: after an excursion at
|
||||||
|
* a month a second, real time carries on from months ahead, and the map is still away from now.
|
||||||
|
*/
|
||||||
|
readonly atNow = signal(true);
|
||||||
|
|
||||||
|
private anchorJd = dateToJulianDate();
|
||||||
|
private anchorWallMs = Date.now();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Julian date for this instant, at the rate the reader chose, held to {@link CLOCK_WINDOW}: a
|
||||||
|
* clock run past either end stops there, at real time turned back into the window, as if the
|
||||||
|
* reader had set that date. Unheld, a month a second carried it past AD 3000, where the planets'
|
||||||
|
* elements were never fitted, and before AD 1, where `toISOString` writes a six-digit year the
|
||||||
|
* date strip, the note and the date field cut in the wrong places ("-000001-12-01 00 UTC").
|
||||||
|
*/
|
||||||
|
julianDate(): number {
|
||||||
|
const jd = this.anchorJd + ((Date.now() - this.anchorWallMs) * this.rate()) / MS_PER_DAY;
|
||||||
|
if (jd >= WINDOW_JD.min && jd <= WINDOW_JD.max) {
|
||||||
|
return jd;
|
||||||
|
}
|
||||||
|
this.anchorJd = jd < WINDOW_JD.min ? WINDOW_JD.min : WINDOW_JD.max;
|
||||||
|
this.anchorWallMs = Date.now();
|
||||||
|
this.rate.set(jd < WINDOW_JD.min ? 1 : -1);
|
||||||
|
return this.anchorJd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same instant as a date, for anything that prints it.
|
||||||
|
*
|
||||||
|
* Rounded to the millisecond, which is all a `Date` holds: a Julian date near 2 461 000 has
|
||||||
|
* about a twentieth of a millisecond of resolution left in a double, and `new Date` truncates
|
||||||
|
* what is left rather than rounding it, so ten seconds came back as 9.999.
|
||||||
|
*/
|
||||||
|
date(): Date {
|
||||||
|
return new Date(Math.round((this.julianDate() - JULIAN_DATE_AT_EPOCH) * MS_PER_DAY));
|
||||||
|
}
|
||||||
|
|
||||||
|
setRate(secondsPerSecond: number): void {
|
||||||
|
this.anchorJd = this.julianDate();
|
||||||
|
this.anchorWallMs = Date.now();
|
||||||
|
this.rate.set(secondsPerSecond);
|
||||||
|
if (secondsPerSecond !== 1) {
|
||||||
|
this.atNow.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jumps the clock to a date, from which it carries on at whatever rate it was running at.
|
||||||
|
* Refuses one outside {@link CLOCK_WINDOW}, rather than draw planets where elements that were
|
||||||
|
* never fitted there put them.
|
||||||
|
*/
|
||||||
|
setDate(date: Date): boolean {
|
||||||
|
const ms = date.getTime();
|
||||||
|
// Written so that NaN, an unparsable field, fails it too.
|
||||||
|
if (!(ms >= WINDOW_MS.min && ms <= WINDOW_MS.max)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.anchorJd = dateToJulianDate(date);
|
||||||
|
this.anchorWallMs = Date.now();
|
||||||
|
this.atNow.set(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back to now, at real time — the state the map opens in. */
|
||||||
|
reset(): void {
|
||||||
|
this.anchorJd = dateToJulianDate();
|
||||||
|
this.anchorWallMs = Date.now();
|
||||||
|
this.rate.set(TIME_RATES[0].secondsPerSecond);
|
||||||
|
this.atNow.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Body textures
|
||||||
|
|
||||||
|
`bodies/` holds the surface maps `src/app/shared/rendering/texture-catalog.ts` wraps round the
|
||||||
|
bodies, keyed by their ids in `bodies.json`. Every map is simple cylindrical (equirectangular),
|
||||||
|
360 by 180 degrees, twice as wide as tall, with **longitude 0 in the middle and east to the right**,
|
||||||
|
which is the frame `MAP_TO_BODY` in `body-orientation.ts` puts onto the IAU body frame. The IAU
|
||||||
|
prime meridian (W) then turns longitude 0 to where it belongs at any date.
|
||||||
|
|
||||||
|
## Solar System Scope (CC BY 4.0)
|
||||||
|
|
||||||
|
`mercury`, `venus`, `earth`, `mars`, `saturn`, `uranus`, `neptune`, `moon`, `sun`, `saturn_ring`
|
||||||
|
and the skybox come from the Solar System Scope texture pack, and `jupiter` from its 8k pack, via
|
||||||
|
Wikimedia Commons. See each file's Commons page for the original credit line.
|
||||||
|
|
||||||
|
`venus.jpg` is kept turned 180 degrees from the pack's file, which is the Magellan radar map with
|
||||||
|
south up and east to the left: there Maxwell Montes (65.2 N, 3.3 E in the IAU Gazetteer), the
|
||||||
|
brightest feature north or south of 50 degrees, sat at 63 S, 9 W, with Lakshmi Planum east of it
|
||||||
|
instead of west. Turned back (PIL `ROTATE_180`, re-saved on the file's own quantisation tables, 0.03
|
||||||
|
grey levels from the exact turn), its brightest point is at 63.7 N, 8.3 E, with Lakshmi to the west.
|
||||||
|
`texture-catalog.spec.ts` pins the checked file's SHA-256.
|
||||||
|
|
||||||
|
`saturn_ring.png` is a 1 280 by 78 px strip. Its x axis runs straight out from Saturn: read off its
|
||||||
|
alpha, the C ring's inner edge (74 490 km) is at px 91, the B ring's inner and outer edges (92 000
|
||||||
|
and 117 580 km) at 404.5 and 860, the A ring's outer edge (136 775 km) at 1 204 and the F ring
|
||||||
|
(140 180 km) at 1 267.5, all within 1.8 px of 55.9 km a pixel. So its left edge stands for
|
||||||
|
69 400 km and its right edge for 141 000 km (`SATURN_RING_INNER_KM`, `SATURN_RING_OUTER_KM`). The
|
||||||
|
Cassini Division's outer edge (122 170 km) is drawn 30 px (1 700 km) too far in.
|
||||||
|
|
||||||
|
## Mission mosaics (public domain)
|
||||||
|
|
||||||
|
Each was downloaded from the URL below and processed the same way (script:
|
||||||
|
`build_maps.py`, kept with the measurements outside the repository):
|
||||||
|
|
||||||
|
1. Pixels the source leaves unmapped (value 0 in every band, its no-data value) are set to one
|
||||||
|
flat grey: the mean of the mapped surface. They are never filled with invented terrain. Titan's
|
||||||
|
source marks its largest gap another way, with a flat grey of its own (147 and 148, its two
|
||||||
|
commonest values; "the uniform gray area in the northern hemisphere indicates a gap in the
|
||||||
|
imaging coverage", PIA19658), which the table counts as unmapped too: 1.09% of `titan.jpg`'s
|
||||||
|
pixels, 0.87% of the sphere, where its zeros alone were 0.02%.
|
||||||
|
2. Downsampled by area averaging (PIL `BOX`) to 2 048 by 1 024 for bodies over 1 000 km in radius
|
||||||
|
and 1 024 by 512 for the rest.
|
||||||
|
3. Rolled half a turn where the source is centred on longitude 180, so longitude 0 is in the
|
||||||
|
middle. Every source already has east to the right. The centre was read from each file's
|
||||||
|
GeoTIFF tags (central meridian plus the tie point of its left edge), not from its label: Rhea's
|
||||||
|
and Enceladus's labels say `CENTER_LONGITUDE = 180` over an image centred on 0.
|
||||||
|
4. Saved as JPEG at quality 85 (Europa 82, to stay under 400 KB), greyscale where the source is.
|
||||||
|
|
||||||
|
Each was then checked by eye against the IAU Gazetteer: the named feature lies where its
|
||||||
|
coordinates put it on the processed map. "Black" is the share of pixels darker than 8 of 255 after
|
||||||
|
processing; the disc photographs dropped in PR #33 were 20-43% black sky.
|
||||||
|
|
||||||
|
Their brightness is the mosaics' own, contrast-stretched frame by frame to show terrain: it places
|
||||||
|
features, not albedo. Iapetus shows it most. Its leading hemisphere, Cassini Regio, has an albedo of
|
||||||
|
0.03-0.05 and its trailing one 0.5-0.6 (NASA), about a tenth; on `iapetus.jpg`, between 30 S and
|
||||||
|
30 N, the leading side (30-150 W) averages 83.4 of 255 and the trailing (30-150 E) 108.2, a ratio of
|
||||||
|
0.77, as in the USGS source (83.3 and 108.1) and the DLR PDS map. So the drawn Iapetus is a shade
|
||||||
|
darker on one side, where the real one is coal against snow. No map here was rescaled to published
|
||||||
|
photometry.
|
||||||
|
|
||||||
|
| Map | Source | Mission, credit | Checked against | Unmapped (grey) | Black | Size |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| `phobos.jpg` | [USGS](https://asc-pds-services.s3.us-west-2.amazonaws.com/mosaic/Phobos_Viking_Mosaic_40ppd_DLRcontrol.tif) Phobos Viking Mosaic 40ppd (DLR controlled) | Viking Orbiter, with Mars Express images; P. Stooke after Simonelli et al. 1993, PDS Stooke Small Bodies Maps | Stickney (1 N, 49 W) | 0.03% | 0.008% | 1024x512, 119 KB |
|
||||||
|
| `deimos.jpg` | [PDS SBN](https://sbnarchive.psi.edu/pds3/multi_mission/MULTI_SA_MULTI_6_STOOKEMAPS_V3_0/document/m2deimos/deimos_cyl_viking_mro.jpg) Stooke Small Bodies Maps V3.0, Deimos simple cylindrical mosaic, 20 px/deg | Viking Orbiter, with MRO HiRISE; P. Stooke and colleagues, control after P. Thomas (Cornell) | Swift (12.5 N, 1.8 E); the set's leading and trailing sheets (see below) | 0% | 0% | 1024x512, 55 KB |
|
||||||
|
| `io.jpg` | [USGS](https://asc-pds-services.s3.us-west-2.amazonaws.com/mosaic/Io_GalileoSSI-Voyager_Global_Mosaic_ClrMerge_1km.tif) Io Galileo SSI-Voyager Global Mosaic, colour merge, 1 km | Galileo SSI and Voyager; USGS Astrogeology | Pele, Loki, Prometheus | 0.02% | 0% | 2048x1024, 283 KB |
|
||||||
|
| `europa.jpg` | [USGS](https://asc-pds-services.s3.us-west-2.amazonaws.com/mosaic/Europa_Voyager_GalileoSSI_global_mosaic_500m.tif) Europa Voyager-Galileo SSI Global Mosaic 500 m | Voyager and Galileo SSI; Archinal et al., USGS | Pwyll (25 S, 271 W) | 4.33% (polar gaps) | 0% | 2048x1024, 386 KB |
|
||||||
|
| `ganymede.jpg` | [USGS](https://asc-pds-services.s3.us-west-2.amazonaws.com/mosaic/Ganymede_Voyager_GalileoSSI_Global_ClrMosaic_1435m.tif) Ganymede Voyager-Galileo SSI Colour Global Mosaic 1.4 km | Voyager and Galileo SSI; USGS | Osiris, Tros, Galileo Regio | 3.63% (polar gaps) | 0% | 2048x1024, 363 KB |
|
||||||
|
| `callisto.jpg` | [USGS](https://asc-pds-services.s3.us-west-2.amazonaws.com/mosaic/Callisto_Voyager_GalileoSSI_global_mosaic_1km.tif) Callisto Voyager-Galileo SSI Global Mosaic 1 km | Voyager and Galileo SSI; USGS | Valhalla, Asgard | 3.90% (polar gaps) | 0% | 2048x1024, 344 KB |
|
||||||
|
| `mimas.jpg` | [PDS](https://planetarydata.jpl.nasa.gov/img/data/carto/coiss_3006/extras/full/images/SM_1M_0_0_SIMP.IMG.png) COISS_3006, Cassini ISS cartographic map of Mimas | Cassini ISS; DLR and FU Berlin (Roatsch et al.), NASA PDS | Herschel (1 N, 112 W) | 0.01% | 0.042% | 1024x512, 155 KB |
|
||||||
|
| `enceladus.jpg` | [USGS](https://asc-pds-services.s3.us-west-2.amazonaws.com/mosaic/Enceladus_Cassini_mosaic_global_110m.tif) Enceladus Cassini Global Mosaic 110 m | Cassini ISS; NASA/JPL/Space Science Institute | Ali Baba, Aladdin, Salih | 0.06% | 0.020% | 1024x512, 168 KB |
|
||||||
|
| `tethys.jpg` | [USGS](https://asc-pds-services.s3.us-west-2.amazonaws.com/mosaic/Tethys_Cassini_mosaic_global_293m.tif) Tethys Cassini Global Mosaic 293 m | Cassini ISS; NASA/JPL/Space Science Institute | Odysseus, Penelope | 0.04% | 0.009% | 1024x512, 182 KB |
|
||||||
|
| `dione.jpg` | [USGS](https://asc-pds-services.s3.us-west-2.amazonaws.com/mosaic/Dione_Cassini_Voyager_mosaic_global_154m.tif) Dione Cassini-Voyager Global Mosaic 154 m | Cassini ISS and Voyager; NASA/JPL/Space Science Institute | Creusa, Evander | 0.18% | 0.011% | 1024x512, 195 KB |
|
||||||
|
| `rhea.jpg` | [USGS](https://asc-pds-services.s3.us-west-2.amazonaws.com/mosaic/Rhea_Cassini_Voyager_mosaic_global_417m.tif) Rhea Cassini-Voyager Global Mosaic 417 m | Cassini ISS and Voyager; NASA/JPL/Space Science Institute | Inktomi, Tirawa | 0% | 0.003% | 1024x512, 128 KB |
|
||||||
|
| `titan.jpg` | [USGS](https://asc-pds-services.s3.us-west-2.amazonaws.com/mosaic/Titan_ISS_P19658_Mosaic_Global_4km.tif) Titan Cassini ISS Global Mosaic 4 km (938 nm, through the haze) | Cassini ISS; NASA/JPL-Caltech/SSI | Xanadu, Shangri-La, Belet | 1.1% (48-68 N, 37 W to 25 E: the source's own flat grey, see step 1) | 0.078% | 2048x1024, 294 KB |
|
||||||
|
| `iapetus.jpg` | [USGS](https://asc-pds-services.s3.us-west-2.amazonaws.com/mosaic/Iapetus_Cassini_Voyager_mosaic_global_783m.tif) Iapetus Cassini-Voyager Global Mosaic 783 m | Cassini ISS and Voyager; NASA/JPL/Space Science Institute | Cassini Regio (leading side, 90 W), Engelier | 0% | 0.019% | 1024x512, 159 KB |
|
||||||
|
| `phoebe.jpg` | [PDS](https://planetarydata.jpl.nasa.gov/img/data/carto/coiss_3001/extras/full/images/SP_1M_0_0_SIMP.IMG.png) COISS_3001, Cassini ISS cartographic map of Phoebe | Cassini ISS; DLR and FU Berlin (Roatsch et al.), NASA PDS | Jason (16 N, 318 W) | 20.41% (the north) | 0.344% (shadows) | 1024x512, 78 KB |
|
||||||
|
| `triton.jpg` | [USGS](https://asc-pds-services.s3.us-west-2.amazonaws.com/mosaic/Triton_Voyager2_ClrMosaic_GlobalFill_600m.tif) Triton Voyager 2 Global Colour Mosaic 600 m (PIA18668) | Voyager 2; P. Schenk, NASA/JPL/LPI | Leviathan Patera; southern cap | 38.59% (the north Voyager 2 never saw) | 0% | 2048x1024, 205 KB |
|
||||||
|
| `ceres.jpg` | [USGS](https://asc-pds-services.s3.us-west-2.amazonaws.com/mosaic/Ceres_Dawn_FC_DLR_global_20ppd_Oct2015.tif) Ceres Dawn FC Global Mosaic, HAMO, Oct 2015 | Dawn Framing Camera; DLR, NASA/JPL | Occator (20 N, 239 E), Haulani (6 N, 11 E) | 3.60% (south pole) | 0.049% | 1024x512, 165 KB |
|
||||||
|
| `pluto.jpg` | [USGS](https://asc-pds-services.s3.us-west-2.amazonaws.com/mosaic/Pluto_NewHorizons_Global_Mosaic_300m_Jul2017_8bit.tif) Pluto New Horizons LORRI-MVIC Global Mosaic 300 m | New Horizons; NASA/JHUAPL/SwRI/LPI | Sputnik Planitia (175 E, across 180), Cthulhu, Burney | 31.92% (the south, in winter dark in 2015) | 0.501% (Cthulhu) | 2048x1024, 297 KB |
|
||||||
|
| `charon.jpg` | [USGS](https://asc-pds-services.s3.us-west-2.amazonaws.com/mosaic/Charon_NewHorizons_Global_Mosaic_300m_Jul2017_8bit.tif) Charon New Horizons LORRI-MVIC Global Mosaic 300 m | New Horizons; NASA/JHUAPL/SwRI/LPI | Mordor Macula (north pole), Organa | 34.02% (the south) | 0.549% (Mordor) | 1024x512, 72 KB |
|
||||||
|
|
||||||
|
Licence: every source above is NASA mission imagery, published by USGS Astrogeology or the NASA
|
||||||
|
PDS. The USGS metadata gives access constraints of "public domain" (Io, Triton, Enceladus,
|
||||||
|
Tethys, Dione, Rhea, Iapetus, Ganymede, Phobos) or "none" (the rest), with the use constraint
|
||||||
|
"please cite authors", which the credits column does. Io's colour is Galileo's violet, green and 756 nm
|
||||||
|
filters, which USGS says the eye would see "similar but much more muted"; Ganymede's is the
|
||||||
|
Galileo and Voyager colour mosaic; Triton's is orange, violet and ultraviolet shown as red, green
|
||||||
|
and blue (Smith et al. 1989). Phoebe is irregular (a 106.6 km sphere here), and its map keeps the
|
||||||
|
deep shadows of a single flyby. Phobos's source notes that where images lit
|
||||||
|
from opposite sides meet, the seam was blended for appearance, not geometry. Deimos's map is from
|
||||||
|
the NASA PDS Small Bodies Node archive (MULTI-SA-MULTI-6-STOOKEMAPS-V3.0), which states no use
|
||||||
|
restriction; the credit column cites its author.
|
||||||
|
|
||||||
|
Deimos's map says only "0 longitude at the center", not which way longitude runs, and USGS's own
|
||||||
|
copy of the older version (`wms_basemaps/Deimos/deimoscyl4.jgw`) is georeferenced with longitude 0
|
||||||
|
at its left edge instead. Its frame was settled on the body. Read with longitude 0 in the middle
|
||||||
|
and east to the right, and drawn as a globe seen from outside, north up, the hemisphere centred at
|
||||||
|
90 E matches, unmirrored, the sheet of the same set that Stooke titles "trailing side" and numbers
|
||||||
|
270 (270 W), and the one centred at 90 W matches his "leading side" at 90. A synchronous prograde
|
||||||
|
moon trails at 90 E and leads at 90 W, so that reading is the right one; read the USGS way, the
|
||||||
|
two sheets would land on the wrong hemispheres. A 1 km depression lies at Swift's Gazetteer
|
||||||
|
position (12.5 N, 1.8 E), near the middle; Voltaire (22 N, 3.5 W, 1.9 km) could not be picked out.
|
||||||
|
|
||||||
|
Longitudes follow each body's IAU prime meridian, which the checks above confirm on the maps. For
|
||||||
|
Pluto and Charon that is the right-hand-rule pole of the WGCCRE 2015 report, which New Horizons'
|
||||||
|
maps also use: the Charon-facing hemisphere is centred on longitude 0 and Sputnik Planitia sits on
|
||||||
|
the far side, near 180.
|
||||||
|
|
||||||
|
## Left out
|
||||||
|
|
||||||
|
- **Miranda, Ariel, Umbriel, Titania, Oberon.** Voyager 2 saw only their southern hemispheres,
|
||||||
|
and no public-domain map of them exists at USGS or the PDS. The best maps (P. Schenk 2020, USRA
|
||||||
|
repository, hdl.handle.net/20.500.11753/1687) carry no licence.
|
||||||
|
- **Hyperion, Nereid, Proteus, Eris, Haumea, Makemake.** No public-domain photographic map in
|
||||||
|
simple cylindrical projection (Hyperion's USGS basemap is a relief rendering, not a mosaic).
|
||||||
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 363 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 803 KiB After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 234 KiB |
@@ -1,14 +1,22 @@
|
|||||||
import { statSync } from 'node:fs';
|
import { statSync } from 'node:fs';
|
||||||
|
|
||||||
import { BodyRecord } from '../../src/app/shared/models/body.model';
|
import { BodyRecord, OrbitalElements, RotationalElements } from '../../src/app/shared/models/body.model';
|
||||||
|
import { eclipticToEquatorial, laplacePlaneToEquatorial, raDecToUnitVector } from '../../src/app/shared/astro/coordinates';
|
||||||
|
import { meanElementsAt, positionAtEpoch } from '../../src/app/shared/astro/kepler';
|
||||||
|
import { orientationAt } from '../../src/app/shared/astro/rotational-elements';
|
||||||
import { DeepSkyRecord } from '../../src/app/shared/models/deepsky.model';
|
import { DeepSkyRecord } from '../../src/app/shared/models/deepsky.model';
|
||||||
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
|
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
|
||||||
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
||||||
import { fetchDeepSky } from './fetchDeepSky';
|
import { fetchDeepSky } from './fetchDeepSky';
|
||||||
import { fetchExoplanets } from './fetchExoplanets';
|
import { fetchExoplanets } from './fetchExoplanets';
|
||||||
import { fetchSolarSystem } from './fetchSolarSystem';
|
import { fetchSolarSystem, FREELY_SPINNING_MOONS, offsetFromTrackDeg } from './fetchSolarSystem';
|
||||||
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
|
import { TrackPoint } from './lib/horizons';
|
||||||
import { fetchStars } from './fetchStars';
|
import { subPlanetLongitudeDeg } from './lib/locked-spin';
|
||||||
|
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog, isDesignation } from '../../src/app/shared/models/star-catalog';
|
||||||
|
import { fetchStars, glieseGaiaDesignations } from './fetchStars';
|
||||||
|
import { ARCHIVE_EPOCH, archiveStarId, CATALOGUE_EPOCH } from '../../src/app/shared/astro/host-star-matching';
|
||||||
|
import { foldsInto } from '../../src/app/shared/astro/star-merge';
|
||||||
|
import { propagateProperMotion, raDegDecDistanceToXyz } from '../../src/app/shared/astro/coordinates';
|
||||||
import { describeSources } from './sources/registry';
|
import { describeSources } from './sources/registry';
|
||||||
import { dataPath } from './lib/paths';
|
import { dataPath } from './lib/paths';
|
||||||
|
|
||||||
@@ -20,12 +28,91 @@ function assertCondition(condition: boolean, message: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stars whose magnitude is a stand-in, and distances published without an error. Measured 309 and
|
||||||
|
* 332: the 44 Gaia sources with no G and the 265 archive hosts with neither V nor G; the 250 Gliese
|
||||||
|
* distances (no Hipparcos parallax behind them) and 82 archive hosts the archive gives no error
|
||||||
|
* for. Losing either field loses it for hundreds of thousands of stars.
|
||||||
|
*/
|
||||||
|
const MAX_STARS_WITHOUT_BAND = 1_000;
|
||||||
|
const MAX_STARS_WITHOUT_DISTANCE_ERROR = 1_000;
|
||||||
|
/**
|
||||||
|
* What the errors themselves come to, which the two counts above cannot see: Gaia's parallax_error
|
||||||
|
* stored in milliarcseconds rather than over the parallax still encodes, decodes and passes both.
|
||||||
|
* Measured: the median relative error of the stars at Gaia's distance is 0.33 %, and 1 116 parallax
|
||||||
|
* distances (0.24 % of the stars) have one of a fifth or more, which the card prints as a range.
|
||||||
|
* The mistake above gives 1.92 % and 17 689.
|
||||||
|
*/
|
||||||
|
const MAX_MEDIAN_GAIA_DISTANCE_ERROR = 0.01;
|
||||||
|
const MAX_RANGED_DISTANCE_SHARE = 0.01;
|
||||||
|
/**
|
||||||
|
* HYG stars that keep their own row but sit at Gaia's distance, which fetchStars flags for the card
|
||||||
|
* to say "HYG, Gaia DR3 distance". Measured 8 105; the flag dropped leaves none, and nothing else
|
||||||
|
* notices — the other 61 713 carry it from their Gaia row.
|
||||||
|
*/
|
||||||
|
const MIN_HYG_STARS_AT_GAIA_DISTANCE = 6_000;
|
||||||
|
/**
|
||||||
|
* The other half of placing a star at its more precise distance (8c3a860): a HYG star folded into
|
||||||
|
* its Gaia entry that keeps its Hipparcos distance, which Gaia saturates on. Measured 384, none
|
||||||
|
* before 8c3a860, and 148 with fetchStars handing combine Gaia's error with the Hipparcos distance,
|
||||||
|
* where the two errors tie and Gaia's distance wins — which the floor above cannot see, since that
|
||||||
|
* raises its count to 8 129. The two stars below are what that looks like: Tarazed went back to
|
||||||
|
* Gaia's 178.9 pc, and Eta Leo kept its 389 pc but read "±17 %, HYG, Gaia DR3 distance".
|
||||||
|
*/
|
||||||
|
const MIN_GAIA_STARS_AT_HIPPARCOS_DISTANCE = 300;
|
||||||
|
const HIPPARCOS_PLACED_STARS = [
|
||||||
|
{ id: 96970, name: 'Tarazed', distancePc: 121.07 },
|
||||||
|
{ id: 49441, name: 'Eta Leo', distancePc: 389.11 }
|
||||||
|
];
|
||||||
|
/** Their distances' errors, 2.1 % and 6.3 %, against Gaia's 6.9 % and 17 %. */
|
||||||
|
const MAX_HIPPARCOS_PLACED_ERROR = 0.065;
|
||||||
|
/**
|
||||||
|
* Archive hosts whose colour is read off their temperature, which the card marks "from its
|
||||||
|
* temperature" (23547de). Measured 54; the flag's write dropped from fetchExoplanets leaves none,
|
||||||
|
* and every other check passes, since the round trip compares the flag with itself.
|
||||||
|
*/
|
||||||
|
const MIN_COLOURS_FROM_TEMPERATURE = 40;
|
||||||
|
/**
|
||||||
|
* Archive stars numbered after their host's name (62f81f2), so a refresh keeps each one's id and a
|
||||||
|
* bookmark its star. Measured: 3 276 of the 3 277 take the id their name hashes to, one having
|
||||||
|
* probed past a taken one. Numbered in arrival order, as before, none do, and nothing else fails.
|
||||||
|
*/
|
||||||
|
const MAX_ARCHIVE_IDS_OFF_THEIR_NAME = 5;
|
||||||
|
/**
|
||||||
|
* Every star the naked eye sees, kept at any distance (c64eea0): measured 8 898 of V 6.5 or
|
||||||
|
* brighter, 1 663 of them past 250 pc. With no magnitude handed to placementDistancePc, 7 379 are
|
||||||
|
* left and Rigel, Deneb and Alnilam are gone — and every other check passed, the HYG survivors
|
||||||
|
* going down rather than up. HD 197770 and HD 45291 are two of the twelve only Gaia's bright
|
||||||
|
* sources place, by position; without that lookup they are gone and the count drops by twelve,
|
||||||
|
* which the floor alone would not see.
|
||||||
|
*
|
||||||
|
* HD 45951 is the one only SIMBAD's name for its Gaia source places, HYG's declination for it being
|
||||||
|
* 31.7′ out. Placed along HYG's direction instead, every check here passed, with the star drawn
|
||||||
|
* twice: there, and as its bare source 31.7′ away. So it is held to that source by designation.
|
||||||
|
*
|
||||||
|
* Twenty HYG rows of V 6.5 or brighter are still left out, for want of a distance: neither HYG nor
|
||||||
|
* Hipparcos gives one, and Gaia DR3 has no source brighter than G 7.5 with a parallax five times
|
||||||
|
* its error within a minute of arc, nor under the name SIMBAD gives. They are β Phe, φ Cas, χ Aur,
|
||||||
|
* ο¹ Cen, Polis, 16 Sgr, ρ Cas, η Car, and HD 47240, 50820, 90772, 97534, 100198, 101205, 101947,
|
||||||
|
* 129092, 151804, 185936, 202214 and 212466. A 21st, θ¹ Ori A (HYG 26155), is left out on purpose:
|
||||||
|
* its V 4.98 and O7 are Hipparcos's for it and a companion together, and SIMBAD names it a source
|
||||||
|
* of G 6.63 at 378 pc, which the map does not keep; placed, it was drawn brighter than θ¹ Ori C.
|
||||||
|
*/
|
||||||
|
const MIN_NAKED_EYE_STARS = 8_800;
|
||||||
|
const NAKED_EYE_MAGNITUDE_V = 6.5;
|
||||||
|
const REQUIRED_NAKED_EYE_STARS = ['Rigel', 'Deneb', 'Alnilam', 'HD 197770', 'HD 45291', 'HD 45951'];
|
||||||
|
const IDENTIFIED_NAKED_EYE_STARS = [{ name: 'HD 45951', gaiaDesignation: 'Gaia DR3 3369454521490604416' }];
|
||||||
|
const BLENDED_NAKED_EYE_ROWS = [{ id: 26155, name: 'θ¹ Ori A' }];
|
||||||
|
|
||||||
function validateStars(stars: StarRecord[]): void {
|
function validateStars(stars: StarRecord[]): void {
|
||||||
assertCondition(stars.length > 0, 'No stars were produced.');
|
assertCondition(stars.length > 0, 'No stars were produced.');
|
||||||
|
|
||||||
const ids = new Set<number>();
|
const ids = new Set<number>();
|
||||||
for (const star of stars) {
|
for (const star of stars) {
|
||||||
assertCondition(Number.isFinite(star.id), `Star has a non-numeric id: ${JSON.stringify(star)}`);
|
assertCondition(Number.isFinite(star.id), `Star has a non-numeric id: ${JSON.stringify(star)}`);
|
||||||
|
// Under 2^30, which V8 keeps unboxed; past it every id is a heap number, and the app's boot
|
||||||
|
// task grew by 230 ms when the nearby Gaia stars were numbered from 2 000 000 000.
|
||||||
|
assertCondition(star.id >= 0 && star.id < 2 ** 30, `Star ${star.id} has an id outside 0 to 2^30.`);
|
||||||
assertCondition(!ids.has(star.id), `Duplicate star id: ${star.id}`);
|
assertCondition(!ids.has(star.id), `Duplicate star id: ${star.id}`);
|
||||||
ids.add(star.id);
|
ids.add(star.id);
|
||||||
assertCondition(!!star.name, `Star ${star.id} has no name.`);
|
assertCondition(!!star.name, `Star ${star.id} has no name.`);
|
||||||
@@ -46,7 +133,108 @@ function validateStars(stars: StarRecord[]): void {
|
|||||||
assertCondition(decoded[i].id === stars[i].id && decoded[i].name === stars[i].name, `Star catalogue round-trip altered record ${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].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.`);
|
assertCondition(decoded[i].colorIndex === null === (stars[i].colorIndex === null), `Star catalogue round-trip changed whether star ${stars[i].id} has a colour index.`);
|
||||||
|
assertCondition(
|
||||||
|
decoded[i].magnitudeBand === stars[i].magnitudeBand &&
|
||||||
|
decoded[i].colorSystem === stars[i].colorSystem &&
|
||||||
|
decoded[i].distanceFromGaia === !!stars[i].distanceFromGaia &&
|
||||||
|
decoded[i].colorFromTemperature === !!stars[i].colorFromTemperature,
|
||||||
|
`Star catalogue round-trip changed the photometry of star ${stars[i].id}.`
|
||||||
|
);
|
||||||
|
// Stored as its square root in 65 535ths, up to 100 %; see `star-catalog.ts`.
|
||||||
|
const error = stars[i].distanceError;
|
||||||
|
assertCondition(
|
||||||
|
error === undefined ? decoded[i].distanceError === undefined : Math.abs(Math.sqrt(decoded[i].distanceError!) - Math.sqrt(Math.min(1, error))) <= 0.5 / 65_535 + 1e-9,
|
||||||
|
`Star catalogue round-trip changed the distance error of star ${stars[i].id}.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What each star says it was measured in. A band or an error dropped on the way still encodes,
|
||||||
|
// decodes and draws; it shows only as a card reading "Not measured" for a star that was.
|
||||||
|
const withoutBand = stars.filter((star) => star.magnitudeBand === undefined).length;
|
||||||
|
assertCondition(withoutBand <= MAX_STARS_WITHOUT_BAND, `${withoutBand} stars have no magnitude band (at most ${MAX_STARS_WITHOUT_BAND} expected) — the band is being lost.`);
|
||||||
|
const withoutError = stars.filter((star) => star.id !== SUN_STAR_ID && star.distanceError === undefined).length;
|
||||||
|
assertCondition(
|
||||||
|
withoutError <= MAX_STARS_WITHOUT_DISTANCE_ERROR,
|
||||||
|
`${withoutError} stars have no distance error (at most ${MAX_STARS_WITHOUT_DISTANCE_ERROR} expected) — the parallax errors are being lost.`
|
||||||
|
);
|
||||||
|
console.log(` ${withoutBand} stars with a stand-in magnitude; ${withoutError} distances without a published error.`);
|
||||||
|
|
||||||
|
const gaiaErrors = stars
|
||||||
|
.filter((star) => star.distanceFromGaia && star.distanceError !== undefined)
|
||||||
|
.map((star) => star.distanceError!)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
const medianGaiaError = gaiaErrors[Math.floor(gaiaErrors.length / 2)] ?? 0;
|
||||||
|
assertCondition(
|
||||||
|
medianGaiaError <= MAX_MEDIAN_GAIA_DISTANCE_ERROR,
|
||||||
|
`The median error of Gaia's distances is ${(medianGaiaError * 100).toFixed(2)} % (at most ${MAX_MEDIAN_GAIA_DISTANCE_ERROR * 100} % expected) — the parallax errors are no longer relative.`
|
||||||
|
);
|
||||||
|
// The archive's errors are on the distance and never printed as a range; see formatDistance.
|
||||||
|
const ranged = stars.filter((star) => star.source !== 'exoplanet-archive' && (star.distanceError ?? 0) >= 0.2).length;
|
||||||
|
assertCondition(
|
||||||
|
ranged <= stars.length * MAX_RANGED_DISTANCE_SHARE,
|
||||||
|
`${ranged} parallax distances have an error of a fifth or more (at most ${MAX_RANGED_DISTANCE_SHARE * 100} % of stars expected).`
|
||||||
|
);
|
||||||
|
const hygAtGaiaDistance = stars.filter((star) => star.source === 'hyg' && star.distanceFromGaia).length;
|
||||||
|
assertCondition(
|
||||||
|
hygAtGaiaDistance >= MIN_HYG_STARS_AT_GAIA_DISTANCE,
|
||||||
|
`Only ${hygAtGaiaDistance} HYG stars are flagged at Gaia's distance (at least ${MIN_HYG_STARS_AT_GAIA_DISTANCE} expected) — fetchStars no longer says whose parallax it placed them by.`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` Gaia distances a median ${(medianGaiaError * 100).toFixed(2)} % uncertain; ${ranged} parallax distances ranged; ${hygAtGaiaDistance} HYG stars at Gaia's distance.`
|
||||||
|
);
|
||||||
|
|
||||||
|
const nakedEye = stars.filter((star) => star.magnitudeBand === 'V' && star.magnitude <= NAKED_EYE_MAGNITUDE_V && star.id !== SUN_STAR_ID).length;
|
||||||
|
assertCondition(
|
||||||
|
nakedEye >= MIN_NAKED_EYE_STARS,
|
||||||
|
`Only ${nakedEye} stars of V ${NAKED_EYE_MAGNITUDE_V} or brighter (at least ${MIN_NAKED_EYE_STARS} expected) — naked-eye stars are no longer kept at any distance.`
|
||||||
|
);
|
||||||
|
for (const name of REQUIRED_NAKED_EYE_STARS) {
|
||||||
|
assertCondition(stars.some((star) => star.name === name), `${name} is missing — naked-eye stars are no longer kept wherever a survey places them.`);
|
||||||
|
}
|
||||||
|
for (const expected of IDENTIFIED_NAKED_EYE_STARS) {
|
||||||
|
assertCondition(
|
||||||
|
stars.some((star) => star.name === expected.name && star.gaiaDesignation === expected.gaiaDesignation),
|
||||||
|
`${expected.name} is not on ${expected.gaiaDesignation}, the source SIMBAD names it as — it is drawn where HYG has it, beside that source.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const blended of BLENDED_NAKED_EYE_ROWS) {
|
||||||
|
assertCondition(!stars.some((star) => star.id === blended.id), `${blended.name} is drawn in the V and type of Hipparcos's blend of it with a companion.`);
|
||||||
|
}
|
||||||
|
console.log(` ${nakedEye} naked-eye stars.`);
|
||||||
|
|
||||||
|
const gaiaAtHipparcosDistance = stars.filter((star) => star.source === 'gaia' && star.magnitudeBand === 'V' && !star.distanceFromGaia).length;
|
||||||
|
assertCondition(
|
||||||
|
gaiaAtHipparcosDistance >= MIN_GAIA_STARS_AT_HIPPARCOS_DISTANCE,
|
||||||
|
`Only ${gaiaAtHipparcosDistance} HYG stars folded into Gaia keep their Hipparcos distance (at least ${MIN_GAIA_STARS_AT_HIPPARCOS_DISTANCE} expected) — the more precise distance no longer wins.`
|
||||||
|
);
|
||||||
|
for (const expected of HIPPARCOS_PLACED_STARS) {
|
||||||
|
const star = stars.find((candidate) => candidate.id === expected.id);
|
||||||
|
const distancePc = star && Math.hypot(star.x, star.y, star.z);
|
||||||
|
assertCondition(
|
||||||
|
star !== undefined && Math.abs(distancePc! / expected.distancePc - 1) < 0.01 && !star.distanceFromGaia && (star.distanceError ?? 1) < MAX_HIPPARCOS_PLACED_ERROR,
|
||||||
|
`${expected.name} is at ${distancePc?.toFixed(1)} pc, error ${star?.distanceError}, Gaia's: ${star?.distanceFromGaia} — expected its Hipparcos ${expected.distancePc} pc and error.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const coloursFromTemperature = stars.filter((star) => star.colorFromTemperature).length;
|
||||||
|
assertCondition(
|
||||||
|
coloursFromTemperature >= MIN_COLOURS_FROM_TEMPERATURE,
|
||||||
|
`Only ${coloursFromTemperature} colours are marked as read off a temperature (at least ${MIN_COLOURS_FROM_TEMPERATURE} expected) — fetchExoplanets no longer says so.`
|
||||||
|
);
|
||||||
|
const archiveStars = stars.filter((star) => star.source === 'exoplanet-archive');
|
||||||
|
const offTheirName = archiveStars.filter((star) => star.id !== archiveStarId(star.name, new Set())).length;
|
||||||
|
assertCondition(
|
||||||
|
offTheirName <= MAX_ARCHIVE_IDS_OFF_THEIR_NAME,
|
||||||
|
`${offTheirName} of the ${archiveStars.length} archive stars have an id other than their name's (at most ${MAX_ARCHIVE_IDS_OFF_THEIR_NAME} expected) — a refresh would renumber them.`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` ${gaiaAtHipparcosDistance} Gaia stars at their Hipparcos distance; ${coloursFromTemperature} colours from a temperature; ${offTheirName} archive ids off their name.`
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// Hipparcos's mark on a classification it does not print in full reached the card as "Spectral
|
||||||
|
// type A0m...", for Sirius and 2 126 other stars, which reads as text the app cut short.
|
||||||
|
const dotted = stars.filter((star) => star.spectralType.endsWith('...')).length;
|
||||||
|
assertCondition(dotted === 0, `${dotted} spectral types end in "..." — fetchStars no longer trims Hipparcos's mark from them.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,31 +247,52 @@ function validateStars(stars: StarRecord[]): void {
|
|||||||
*
|
*
|
||||||
* A star kept twice leaves its two entries near each other on the sky, from *different* sources —
|
* A star kept twice leaves its two entries near each other on the sky, from *different* sources —
|
||||||
* one catalogue does not list a star twice. Under an arcsecond that is never two stars at this
|
* one catalogue does not list a star twice. Under an arcsecond that is never two stars at this
|
||||||
* depth, so every such pair is a miss. Nineteen survive today, all of them a second HYG row
|
* depth, so every such pair is a miss. Twenty-two survive today; the nineteen first counted were
|
||||||
* wanting a Gaia entry that already absorbed one (Gliese lists some doubles twice); the merge
|
* all a second HYG row wanting a Gaia entry that already absorbed one (Gliese lists some doubles
|
||||||
* that trusted a Hipparcos parallax over direction left 1 112.
|
* twice), and the merge that trusted a Hipparcos parallax over direction left 1 112.
|
||||||
*
|
*
|
||||||
* The other failure leaves no close pair at all, because proper motion had already carried the
|
* The other failure leaves no close pair at all, because proper motion had already carried the
|
||||||
* two entries tens of arcseconds apart — the 2026-08-24 refresh, where HYG sat at epoch 2000.0
|
* two entries tens of arcseconds apart — the 2026-08-24 refresh, where HYG sat at epoch 2000.0
|
||||||
* and Gaia at J2016.0. What it does leave is HYG rows that found no counterpart: 36 056 of them
|
* and Gaia at J2016.0. What it does leave is HYG rows that found no counterpart: 36 056 of them
|
||||||
* against the 10 886 today, and no counterpart was possible for most of those. Two thirds of them,
|
* against the 11 464 today, and no counterpart was possible for most of those. 8 307 of them are
|
||||||
* 6 835, are the stars Gaia measures but the main query never downloads, because Gaia's parallax
|
* every star beyond 250 pc but the archive's planet hosts, which the main query never downloads: 1 660
|
||||||
* puts them past `ETL_GAIA_DISTANCE_PC` while Hipparcos put them inside `ETL_STAR_DISTANCE_PC`;
|
* naked-eye stars kept at any distance, and 6 647 fainter ones that Hipparcos put inside
|
||||||
* they are every star in the published catalogue beyond 250 pc. The rest are what Gaia genuinely
|
* `ETL_STAR_DISTANCE_PC` while Gaia's parallax puts them past `ETL_GAIA_DISTANCE_PC`. The other
|
||||||
* lacks: bright stars it saturates on, red dwarfs past its magnitude cut. So the headroom left to
|
* 3 156 are what Gaia genuinely lacks: 1 202 brighter than V 8, which it saturates on or measures
|
||||||
* the ceiling tracks the gap between those two cutoffs as much as Gaia's completeness.
|
* poorly, 1 793 between 8 and 12, and 161 fainter, 111 of them Gliese stars within 50 pc that
|
||||||
|
* neither of its queries holds. So the headroom left to the ceiling tracks the gap between those two cutoffs as
|
||||||
|
* much as Gaia's completeness.
|
||||||
*
|
*
|
||||||
* This bounds a merge that went wrong, and — loosely — a Gaia download that came back short: a
|
* This bounds a merge that went wrong, and — loosely — a Gaia download that came back short: a
|
||||||
* truncated answer leaves the HYG rows whose counterpart it dropped without one, so survivors go
|
* truncated answer leaves the HYG rows whose counterpart it dropped without one, so survivors go
|
||||||
* *up*, not down. Measured against the published catalogue: 10 886 today, 11 004 at nine tenths of
|
* *up*, not down. Measured on the main query when it was the only one, with 10 886 survivors
|
||||||
* the rows, 12 711 at half, 16 258 at a third. So this ceiling only catches a truncation past about
|
* against today's 11 464: 11 004 at nine tenths of its rows, 12 711 at half, 16 258 at a third. So
|
||||||
* two thirds, and `fetchGaiaStars` catches the shallower ones with its own row floor.
|
* this ceiling only catches a deep truncation, and `fetchGaiaStars` catches the shallower ones
|
||||||
|
* with a row floor on each query.
|
||||||
*/
|
*/
|
||||||
const MAX_UNMERGED_TWINS = 100;
|
const MAX_UNMERGED_TWINS = 100;
|
||||||
|
/**
|
||||||
|
* Gliese-only rows beside the Gaia entry SIMBAD names as the same star, which no geometry saw:
|
||||||
|
* 49 beside a bare one before `foldByIdentity`, two of them false stars inside 10 pc (GJ 2097 at
|
||||||
|
* 6.41 pc and GJ 4285 at 6.80, which Gaia has at 24.47 and 28.25), and 10 beside one a Hipparcos row
|
||||||
|
* already described, Gl 251 at 5.76 pc beside HD 265866. The twin count above does not see them,
|
||||||
|
* being up to minutes of arc apart.
|
||||||
|
*
|
||||||
|
* That count takes SIMBAD's map from the function the fold takes it from, so a slip in the map
|
||||||
|
* turns both off together: with it empty, GJ 2097 and GJ 4285 were back inside 10 pc and the count
|
||||||
|
* read none. These are checked by name and distance instead.
|
||||||
|
*/
|
||||||
|
const MAX_GLIESE_ROWS_BESIDE_THEIR_GAIA_SOURCE = 0;
|
||||||
|
const FOLDED_GLIESE_STARS = [
|
||||||
|
{ name: 'GJ 2097', beyondPc: 20 },
|
||||||
|
{ name: 'GJ 4285', beyondPc: 20 }
|
||||||
|
];
|
||||||
|
/** Gliese-only rows of stars HYG also lists by their Hipparcos row, HD 265866 and HD 304043. */
|
||||||
|
const ABSORBED_GLIESE_ROWS = ['Gl 251', 'Gl 422'];
|
||||||
const MAX_HYG_SURVIVORS = 15_000;
|
const MAX_HYG_SURVIVORS = 15_000;
|
||||||
const TWIN_TOLERANCE_RAD = (1 / 3600) * (Math.PI / 180);
|
const TWIN_TOLERANCE_RAD = (1 / 3600) * (Math.PI / 180);
|
||||||
|
|
||||||
function validateMerge(stars: StarRecord[]): void {
|
function validateMerge(stars: StarRecord[], gaiaDesignationById: ReadonlyMap<number, string>): void {
|
||||||
// Checked first and on its own: an unreachable Gaia is skipped rather than thrown, and would
|
// Checked first and on its own: an unreachable Gaia is skipped rather than thrown, and would
|
||||||
// otherwise surface below as "68 000 HYG stars found no counterpart" — true, and no help.
|
// otherwise surface below as "68 000 HYG stars found no counterpart" — true, and no help.
|
||||||
assertCondition(
|
assertCondition(
|
||||||
@@ -127,36 +336,431 @@ function validateMerge(stars: StarRecord[]): void {
|
|||||||
twins <= MAX_UNMERGED_TWINS,
|
twins <= MAX_UNMERGED_TWINS,
|
||||||
`${twins} stars from different catalogues sit within an arcsecond of each other (at most ${MAX_UNMERGED_TWINS} expected), starting with ${example} — the merge is keeping the same star twice.`
|
`${twins} stars from different catalogues sit within an arcsecond of each other (at most ${MAX_UNMERGED_TWINS} expected), starting with ${example} — the merge is keeping the same star twice.`
|
||||||
);
|
);
|
||||||
console.log(` ${survivors} HYG stars have no Gaia counterpart; ${twins} unmerged cross-catalogue pairs within an arcsecond.`);
|
const byDesignation = new Map(stars.filter((star) => star.gaiaDesignation !== undefined).map((star) => [star.gaiaDesignation!, star]));
|
||||||
|
const beside = stars.filter((star) => {
|
||||||
|
const target = star.source === 'hyg' && star.distanceError === undefined ? byDesignation.get(gaiaDesignationById.get(star.id) ?? '') : undefined;
|
||||||
|
return target !== undefined && foldsInto(target, star);
|
||||||
|
});
|
||||||
|
assertCondition(
|
||||||
|
beside.length <= MAX_GLIESE_ROWS_BESIDE_THEIR_GAIA_SOURCE,
|
||||||
|
`${beside.length} Gliese stars are drawn beside the Gaia source SIMBAD names them as, starting with ${beside[0]?.name} — the identity fold is not being made.`
|
||||||
|
);
|
||||||
|
for (const expected of FOLDED_GLIESE_STARS) {
|
||||||
|
const star = stars.find((candidate) => candidate.name === expected.name);
|
||||||
|
const distancePc = star && Math.hypot(star.x, star.y, star.z);
|
||||||
|
assertCondition(
|
||||||
|
distancePc !== undefined && distancePc > expected.beyondPc,
|
||||||
|
`${expected.name} is at ${distancePc?.toFixed(2)} pc, not beyond ${expected.beyondPc} where Gaia measures it — Gliese stars are no longer folded into their Gaia source.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const name of ABSORBED_GLIESE_ROWS) {
|
||||||
|
assertCondition(!stars.some((star) => star.name === name), `${name} is drawn beside the Hipparcos star it is — Gliese stars are no longer folded into their Gaia source.`);
|
||||||
|
}
|
||||||
|
console.log(` ${survivors} HYG stars have no Gaia counterpart; ${twins} unmerged cross-catalogue pairs within an arcsecond; ${beside.length} Gliese stars beside their own Gaia source.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateBodies(bodies: BodyRecord[]): void {
|
/**
|
||||||
|
* How far a body's mean elements may put it from where Horizons has it, on the one date the ETL
|
||||||
|
* asks Horizons about (2025-01-01), seen from the Sun for a planet and from its planet for a moon.
|
||||||
|
*
|
||||||
|
* Measured on this catalogue: the planets at most 0.10 degrees (Uranus; Standish's own stated
|
||||||
|
* error for his fit is 2 000 arcseconds, 0.56 degrees), the moons at most 1.41 (the Moon, whose
|
||||||
|
* evection and variation, 1.27 and 0.66 degrees, no mean ellipse has). What this catches is a
|
||||||
|
* table read wrongly: a moon read against the ecliptic instead of its Laplace plane, a node run the
|
||||||
|
* wrong way, or a column taken for its neighbour, which put Triton 26 degrees out. Io's periapsis
|
||||||
|
* run forwards put it 0.9 out here, which passes; {@link TRACK_OFFSET_CEILINGS_DEG} catches that.
|
||||||
|
*/
|
||||||
|
const MAX_PLANET_OFFSET_DEG = 0.25;
|
||||||
|
/** Measured on this catalogue: at most 0.0151 (Phoebe and the Moon) once Hyperion prints its current 0.105. */
|
||||||
|
const MAX_ECCENTRICITY_OFFSET = 0.03;
|
||||||
|
const MAX_MOON_OFFSET_DEG = 2.5;
|
||||||
|
const KM_PER_AU = 149597870.7;
|
||||||
|
const DEG_TO_RAD = Math.PI / 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The moons whose table row cannot come within that on this one date, each with a ceiling just
|
||||||
|
* above its offset here (Hyperion 9.41, Iapetus 9.56, Nereid 2.58); see
|
||||||
|
* {@link TRACK_OFFSET_CEILINGS_DEG} for what they reach from 1950 to 2100. Hyperion's was 21, its
|
||||||
|
* worst over twelve dates, which let a row misread by twice its offset through.
|
||||||
|
*/
|
||||||
|
const MOON_OFFSET_CEILINGS_DEG: Record<string, number> = { hyperion: 10, iapetus: 11, nereid: 3 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a moon's or dwarf planet's orbit may stray from Horizons from 1950 to 2100, sampled every
|
||||||
|
* other day (Nereid and Hyperion daily). One date showed each at its best: twelve New Year's Days
|
||||||
|
* gave Nereid 2.6 degrees, and 2025-01-01 alone is all the check above sees. Each card says how
|
||||||
|
* far its own orbit strays over the span (`fetchSolarSystem`), and this holds that figure to
|
||||||
|
* account.
|
||||||
|
*
|
||||||
|
* Measured on this catalogue: at most 2.62 degrees (the Moon, 2010 March 27: no mean ellipse has
|
||||||
|
* its evection or variation; Phoebe reaches 2.58 in 1969, where "within 2.0" was once claimed for
|
||||||
|
* it). Five need their own:
|
||||||
|
*
|
||||||
|
* - Hyperion, 22.23 (2055 Feb 26): held in a 4:3 resonance by Titan; the row's eccentricity,
|
||||||
|
* 0.0232, is less than a quarter of the 0.105 JPL's current table gives.
|
||||||
|
* - Nereid, 11.19 (2039 Nov 1): an eccentricity of 0.75, the largest here, which a mean ellipse
|
||||||
|
* follows least well near periapsis, where the true anomaly runs ten times faster than the mean;
|
||||||
|
* its 360-day year kept every New Year's Day far from one.
|
||||||
|
* - Iapetus, 10.34: the row sits 9.4 degrees behind Horizons at its own epoch, 2000 Jan 1.5, and
|
||||||
|
* keeps that offset; its plane agrees with Horizons' to 0.07 degrees and its period to 0.001 per
|
||||||
|
* cent, so the fault is in the row's longitude, which this has no second source to correct.
|
||||||
|
* - Mimas, 7.42: its orbit carries the 44-degree libration of its resonance with Tethys (see
|
||||||
|
* `orbitFromW` in `fetchSolarSystem.ts`), but not the rest of what Horizons integrates.
|
||||||
|
* - Ceres, 7.12 (1953): the SBDB's elements are osculating, exact at 2026 Jun 9 and drifting
|
||||||
|
* either side; 1.9 by 2050, 5.3 by 2100, and 39 at 1600 on Horizons' own figures.
|
||||||
|
*
|
||||||
|
* And four are held tighter than the rest, each where one reading of its row is all that keeps it
|
||||||
|
* close, and without it the card would quietly restate itself under the general ceiling:
|
||||||
|
*
|
||||||
|
* - Tethys, 0.28, which takes the other half of that libration, 2.23 degrees, from its W. Without
|
||||||
|
* it Tethys strays 2.09.
|
||||||
|
* - Io, 0.07, and Europa, 0.23, whose periapses turn backwards, held by the Laplace resonance at
|
||||||
|
* 2n(Europa) - n(Io), -0.7395 degrees a day (`apsidesRegress`). Read as advancing, Io strays 0.96
|
||||||
|
* and Europa 2.24, and their cards said "within 1.0" and "within 2.3".
|
||||||
|
* - Callisto, 0.08, whose node turns at JPL's current rate and its periapsis's longitude at the
|
||||||
|
* row's (`nodePeriodYears`). On the row's argument its periapsis moves 44 degrees by 2100 and it
|
||||||
|
* strays 0.71; on the row's node, 0.19.
|
||||||
|
*/
|
||||||
|
const MAX_TRACK_OFFSET_DEG = 3;
|
||||||
|
const TRACK_OFFSET_CEILINGS_DEG: Record<string, number> = { hyperion: 23, nereid: 12, iapetus: 11, mimas: 8, ceres: 8, tethys: 0.5, io: 0.2, europa: 0.5, callisto: 0.15 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bodies the IAU WGCCRE 2015 report gives no rotational elements for: Hyperion tumbles, and
|
||||||
|
* Nereid, Eris, Haumea and Makemake have no model. Every other body must carry them, or the
|
||||||
|
* kernel was read wrongly and the body would be drawn on an invented pole.
|
||||||
|
*/
|
||||||
|
const WITHOUT_ROTATIONAL_ELEMENTS = new Set(['hyperion', 'nereid', 'eris', 'haumea', 'makemake']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one moon drawn still: Hyperion, whose page says "Rotational period = Chaotic". Every other
|
||||||
|
* moon without a lock has a measured day; Nereid's page states none, and it was drawn still until
|
||||||
|
* its K2 light curve's 11.594 hours was taken (see its spec).
|
||||||
|
*/
|
||||||
|
const TUMBLING = new Set(['hyperion']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the IAU's day, 360 degrees over W's rate, may be from the period the body's record
|
||||||
|
* carries, as a fraction of it. That period is not always a second source:
|
||||||
|
*
|
||||||
|
* - The eight planets and Phoebe: the one Horizons states. Measured on this catalogue: at most
|
||||||
|
* 1.8e-5 (Jupiter's System III, 9.92492 hours against 9.92510). Neptune is 0.89 per cent out,
|
||||||
|
* because the report takes 15.9663 hours from the cloud features Karkoschka (2011) tracked, where
|
||||||
|
* Horizons keeps Voyager's radio period, 16.11.
|
||||||
|
* - Pluto and Ceres: the IAU's own rate restated. Horizons' 153.29335198 hours for Pluto is 360 over
|
||||||
|
* its W (8.5e-12), and the SBDB's 9.074170 for Ceres, which Horizons prints too, is noted as
|
||||||
|
* derived from the report's 952.1532 degrees a day (3.3e-10).
|
||||||
|
* - The 22 locked moons: their orbit's period, from JPL's satellite table, not a figure from their
|
||||||
|
* Horizons pages ("Synchronous" on eighteen of them, nothing on Titan's or Proteus's). Their W is
|
||||||
|
* turned at that rate (see `lockedToOrbit`, which first holds the kernel's own rate to it within
|
||||||
|
* 1e-5), so here they are 0, but for the Moon and Phobos, whose W keeps its own rate and its
|
||||||
|
* quadratic (1.1e-8 and 3.1e-7).
|
||||||
|
*
|
||||||
|
* What this catches is a rate read in the wrong unit or for the wrong body: Oberon's day for
|
||||||
|
* Titania's is 55 per cent out.
|
||||||
|
*/
|
||||||
|
const MAX_DAY_OFFSET = 1e-4;
|
||||||
|
const DAY_OFFSET_CEILINGS: Record<string, number> = { neptune: 0.01 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the tilt of the IAU's spin axis from the orbit may be from the obliquity Horizons
|
||||||
|
* states. The axis is the IAU's pole, turned end for end where W runs backwards: the report names
|
||||||
|
* a planet's north pole by the side of the solar system it lies on, whichever way the planet turns.
|
||||||
|
* Measured on this catalogue: at most 0.058 degrees (Venus, 177.358 against 177.3). Taken as the
|
||||||
|
* pole alone, Venus comes out at 2.6 degrees and Uranus at 82.2, which is what this catches.
|
||||||
|
*
|
||||||
|
* Pluto's Horizons page states no obliquity: its 119.6 is worked out from the IAU pole itself (see
|
||||||
|
* `BodySpec.obliquityDeg`), so for Pluto this checks only that the kernel's pole and W were read as
|
||||||
|
* written, not the pole against a second source.
|
||||||
|
*/
|
||||||
|
const MAX_OBLIQUITY_OFFSET_DEG = 0.1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far from its planet a locked moon's drawn face may turn: the east longitude, on the IAU's
|
||||||
|
* body-fixed frame, of the direction to the planet from where the mean elements put the moon,
|
||||||
|
* sampled every 135 days over the clock's AD 1 to 3000. Every locked moon's W turns at its orbit's
|
||||||
|
* own rate (see `lockedToOrbit`); at the IAU's own rates, and sampled only from 1950 to 2100, this
|
||||||
|
* let Proteus turn its far side to Neptune at AD 1 (146 degrees), Iapetus 87 degrees, Mimas 52 and
|
||||||
|
* Miranda 23, on dates the clock offers.
|
||||||
|
*
|
||||||
|
* Measured on this catalogue: at most 5.36 degrees (Titan) but for three. The Moon 7.62, at AD 1:
|
||||||
|
* its longitude swings 6.3 either way with its eccentricity, Horizons' too, and W's quadratic, the
|
||||||
|
* tidal slowing its orbit here does not carry, adds 0.75 by then. Mimas 8.89: about 6.3 off on
|
||||||
|
* average because the IAU's W and JPL's mean longitude disagree, and swung 2.3 either way (2e) by
|
||||||
|
* its eccentricity. None of that is Mimas: its measured physical libration is 0.84 degrees
|
||||||
|
* (Tajeddine et al. 2014, Science 346, 322), and W carries none; Horizons, on the same W against its
|
||||||
|
* integrated orbit, runs from -2.7 to 12.7 degrees over 1950-2100 with the 71-year S5 term the
|
||||||
|
* orbit here cancels. Iapetus 15.95, whose row sits 9.4 degrees behind Horizons. What this catches
|
||||||
|
* is an orbit and a W that go round at different rates: the tidal acceleration W carried and the
|
||||||
|
* orbit did not turned Phobos 13.8 degrees from Mars by 2100, and the Mimas-Tethys libration Mimas
|
||||||
|
* 54.5.
|
||||||
|
*/
|
||||||
|
const MAX_SUB_PLANET_LONGITUDE_DEG = 7;
|
||||||
|
const SUB_PLANET_CEILINGS_DEG: Record<string, number> = { moon: 8, mimas: 9.5, iapetus: 16.5 };
|
||||||
|
/**
|
||||||
|
* How far a locked moon's spin axis may lean from the normal of the orbit it is drawn going round,
|
||||||
|
* over the same dates. A locked moon sits in a Cassini state, its axis on its orbit normal as the
|
||||||
|
* node carries both round the Laplace pole, and the IAU's pole goes round on a term of the node's
|
||||||
|
* angle; at the rate the IAU's source had for it and not the drawn orbit's, Rhea's axis is 0.77
|
||||||
|
* degrees off by AD 1 and Triton's 0.51, and an Iapetus pole left on the Laplace pole is 8.30 off
|
||||||
|
* at every date (see `lockedToOrbit`).
|
||||||
|
*
|
||||||
|
* Measured on this catalogue: at most 0.97 degrees (Tethys, whose IAU pole sits 0.69 from its orbit
|
||||||
|
* normal today; Titan 0.94, whose pole the IAU holds still while its node turns in 687 years) but
|
||||||
|
* for four. The Moon 6.98, its real 6.7-degree tilt to its orbit. Phobos 1.81 and Deimos 1.74, and
|
||||||
|
* Proteus 1.09: their IAU poles nod with Mars's and Neptune's precessing poles, the Laplace poles
|
||||||
|
* their orbits are drawn round are fixed.
|
||||||
|
*
|
||||||
|
* And six are held tighter, each where its node terms turned at the node's rate, or its node at
|
||||||
|
* JPL's current rate, are what keep it close: Europa 0.13, Ganymede 0.16, Callisto 0.22, Rhea 0.17,
|
||||||
|
* Miranda 0.23 and Triton 0.15. On the IAU's rates they are 0.33, 0.21, 0.33, 0.77, 0.59 and 0.51,
|
||||||
|
* on a tolerance of 1 per cent Callisto and Rhea are left there, on the node's angle alone and not
|
||||||
|
* its harmonics Triton is 0.29, and on the archived table's node periods Callisto is 0.56 and
|
||||||
|
* Miranda 0.42: all under the general ceiling.
|
||||||
|
*/
|
||||||
|
const MAX_AXIS_FROM_ORBIT_DEG = 1;
|
||||||
|
const AXIS_FROM_ORBIT_CEILINGS_DEG: Record<string, number> = {
|
||||||
|
moon: 7.1,
|
||||||
|
phobos: 2,
|
||||||
|
deimos: 2,
|
||||||
|
proteus: 1.2,
|
||||||
|
europa: 0.25,
|
||||||
|
ganymede: 0.25,
|
||||||
|
callisto: 0.25,
|
||||||
|
rhea: 0.25,
|
||||||
|
miranda: 0.25,
|
||||||
|
triton: 0.25
|
||||||
|
};
|
||||||
|
/** The clock's window, AD 1 to 3000 (`CLOCK_WINDOW` in `time.store.ts`), as Julian dates. */
|
||||||
|
const CLOCK_START_JD = Date.parse('0001-01-01T00:00Z') / 86400000 + 2440587.5;
|
||||||
|
const CLOCK_END_JD = Date.parse('3000-01-01T00:00Z') / 86400000 + 2440587.5;
|
||||||
|
const LOCK_DATES_JD = Array.from({ length: Math.floor((CLOCK_END_JD - CLOCK_START_JD) / 135) + 1 }, (_, index) => CLOCK_START_JD + index * 135);
|
||||||
|
|
||||||
|
function angleBetweenDeg(a: { x: number; y: number; z: number }, b: { x: number; y: number; z: number }): number {
|
||||||
|
const cosine = (a.x * b.x + a.y * b.y + a.z * b.z) / (Math.hypot(a.x, a.y, a.z) * Math.hypot(b.x, b.y, b.z));
|
||||||
|
return (Math.acos(Math.min(1, Math.max(-1, cosine))) * 180) / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Degrees between a body's spin axis — its IAU pole, turned over where W runs backwards — and the normal of the orbit it is drawn going round, at a TDB date. */
|
||||||
|
function axisFromOrbitDeg(body: BodyRecord, rotation: RotationalElements, jd: number): number {
|
||||||
|
const pole = orientationAt(rotation, jd);
|
||||||
|
const pointing = raDecToUnitVector(pole.poleRaDeg / 15, pole.poleDecDeg);
|
||||||
|
const sense = Math.sign(rotation.primeMeridianDeg[1]);
|
||||||
|
const axis = { x: sense * pointing.x, y: sense * pointing.y, z: sense * pointing.z };
|
||||||
|
const { inclinationDeg, longitudeOfAscendingNodeDeg } = meanElementsAt(body.orbit, body.rates, jd);
|
||||||
|
const tilt = inclinationDeg * DEG_TO_RAD;
|
||||||
|
const node = longitudeOfAscendingNodeDeg * DEG_TO_RAD;
|
||||||
|
const normal = { x: Math.sin(tilt) * Math.sin(node), y: -Math.sin(tilt) * Math.cos(node), z: Math.cos(tilt) };
|
||||||
|
return angleBetweenDeg(axis, body.laplacePole ? laplacePlaneToEquatorial(normal, body.laplacePole) : eclipticToEquatorial(normal));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBodies(bodies: BodyRecord[], horizonsOrbits: Map<string, OrbitalElements>, horizonsTracks: Map<string, TrackPoint[]>): void {
|
||||||
assertCondition(bodies.length > 0, 'No solar-system bodies were produced.');
|
assertCondition(bodies.length > 0, 'No solar-system bodies were produced.');
|
||||||
|
|
||||||
const ids = new Set(bodies.map((body) => body.id));
|
const ids = new Set(bodies.map((body) => body.id));
|
||||||
assertCondition(ids.size === bodies.length, 'Duplicate body ids were found.');
|
assertCondition(ids.size === bodies.length, 'Duplicate body ids were found.');
|
||||||
|
|
||||||
|
const offsets: string[] = [];
|
||||||
|
const spins: string[] = [];
|
||||||
for (const body of bodies) {
|
for (const body of bodies) {
|
||||||
const orbitValues = Object.values(body.orbit);
|
const orbitValues = Object.values(body.orbit);
|
||||||
assertCondition(orbitValues.every(Number.isFinite), `Body ${body.id} has non-finite orbital elements.`);
|
assertCondition(orbitValues.every(Number.isFinite), `Body ${body.id} has non-finite orbital elements.`);
|
||||||
|
assertCondition(body.rates.meanMotionDegPerDay > 0, `Body ${body.id} has no mean motion.`);
|
||||||
|
|
||||||
|
// Horizons' elements are osculating, exact at their own epoch; both sets are placed there.
|
||||||
|
const horizons = horizonsOrbits.get(body.id);
|
||||||
|
assertCondition(horizons !== undefined, `Body ${body.id} has no Horizons elements to be checked against.`);
|
||||||
|
const truth = eclipticToEquatorial(positionAtEpoch(horizons!));
|
||||||
|
const mean = positionAtEpoch(meanElementsAt(body.orbit, body.rates, horizons!.epochJd));
|
||||||
|
const offset = angleBetweenDeg(body.laplacePole ? laplacePlaneToEquatorial(mean, body.laplacePole) : eclipticToEquatorial(mean), truth);
|
||||||
|
const ceiling = body.kind === 'moon' ? (MOON_OFFSET_CEILINGS_DEG[body.id] ?? MAX_MOON_OFFSET_DEG) : MAX_PLANET_OFFSET_DEG;
|
||||||
|
assertCondition(
|
||||||
|
offset <= ceiling,
|
||||||
|
`${body.name}'s mean elements put it ${offset.toFixed(2)} degrees from where Horizons has it (at most ${ceiling} expected) — the elements were read wrongly.`
|
||||||
|
);
|
||||||
|
offsets.push(`${body.id} ${offset.toFixed(3)}`);
|
||||||
|
|
||||||
|
// Standish's fit names its own span; every other orbit is measured over 1950-2100, and says so.
|
||||||
|
const track = horizonsTracks.get(body.id);
|
||||||
|
assertCondition(
|
||||||
|
(track !== undefined) === !body.orbitSource.startsWith('JPL approximate mean elements (Standish)'),
|
||||||
|
`${body.name}'s orbit, "${body.orbitSource}", ${track ? 'names its own span' : 'names no span it holds over'}.`
|
||||||
|
);
|
||||||
|
// JPL's satellite table carries no periodic terms: a moon's are from its IAU W, and its card
|
||||||
|
// names the kernel they come from as well as the table.
|
||||||
|
assertCondition(
|
||||||
|
!body.parentBodyId || !body.rates.meanAnomalyTerms || body.orbitSource.includes('NAIF pck00011'),
|
||||||
|
`${body.name}'s orbit carries terms taken from its IAU W, and its card, "${body.orbitSource}", credits only the table.`
|
||||||
|
);
|
||||||
|
if (track) {
|
||||||
|
const worst = Math.max(...track.map((point) => offsetFromTrackDeg(body, point)));
|
||||||
|
const trackCeiling = TRACK_OFFSET_CEILINGS_DEG[body.id] ?? MAX_TRACK_OFFSET_DEG;
|
||||||
|
const stated = Number(body.orbitSource.match(/within ([\d.]+) degrees of Horizons/)?.[1]);
|
||||||
|
assertCondition(
|
||||||
|
worst <= trackCeiling && stated >= worst,
|
||||||
|
`${body.name}'s mean elements put it up to ${worst.toFixed(2)} degrees from Horizons between 1950 and 2100 (at most ${trackCeiling} expected), and its card says "${body.orbitSource}".`
|
||||||
|
);
|
||||||
|
offsets.push(`${body.id} ${worst.toFixed(2)} at worst`);
|
||||||
|
}
|
||||||
|
// The card prints this under "Measured". An osculating eccentricity swings about its mean — the
|
||||||
|
// Moon's by 0.015 here, Phoebe's by as much — but not by the 0.087 Hyperion's older row was out.
|
||||||
|
const printed = body.measuredEccentricity ?? body.orbit.eccentricity;
|
||||||
|
assertCondition(
|
||||||
|
Math.abs(printed - horizons!.eccentricity) <= MAX_ECCENTRICITY_OFFSET,
|
||||||
|
`${body.name}'s card gives an eccentricity of ${printed}, where Horizons' osculating orbit has ${horizons!.eccentricity.toFixed(4)} (at most ${MAX_ECCENTRICITY_OFFSET} apart expected).`
|
||||||
|
);
|
||||||
|
|
||||||
|
// A radius of 0 is what a page whose radius no pattern reads comes out as — Charon's did.
|
||||||
|
assertCondition(body.radiusKm > 0, `Body ${body.id} has no radius; its page states it in a form the ETL does not read.`);
|
||||||
|
// A triaxial body's card gives its mean radius beside its semi-axes, so the two must agree: the
|
||||||
|
// radius of the sphere of the same volume. Measured: Haumea's 797.6 against 797.62.
|
||||||
|
if (body.semiAxesKm) {
|
||||||
|
const volumeRadius = Math.cbrt(body.semiAxesKm[0] * body.semiAxesKm[1] * body.semiAxesKm[2]);
|
||||||
|
assertCondition(
|
||||||
|
Math.abs(volumeRadius / body.radiusKm - 1) < 0.001,
|
||||||
|
`${body.name}'s radius, ${body.radiusKm} km, is not the mean of its semi-axes ${body.semiAxesKm.join(' x ')}, ${volumeRadius.toFixed(1)} km.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotation = body.rotationalElements;
|
||||||
|
assertCondition(
|
||||||
|
(rotation === undefined) === WITHOUT_ROTATIONAL_ELEMENTS.has(body.id),
|
||||||
|
`Body ${body.id} ${rotation ? 'has' : 'has no'} IAU rotational elements, which the report ${rotation ? 'does not give' : 'gives'} for it.`
|
||||||
|
);
|
||||||
|
if (rotation) {
|
||||||
|
const rate = rotation.primeMeridianDeg[1];
|
||||||
|
if (body.rotationPeriodHours !== undefined) {
|
||||||
|
const dayOffset = Math.abs(((360 / Math.abs(rate)) * 24) / Math.abs(body.rotationPeriodHours) - 1);
|
||||||
|
const dayCeiling = DAY_OFFSET_CEILINGS[body.id] ?? MAX_DAY_OFFSET;
|
||||||
|
assertCondition(
|
||||||
|
dayOffset <= dayCeiling,
|
||||||
|
`${body.name}'s IAU day, ${((360 / Math.abs(rate)) * 24).toFixed(5)} hours, is ${dayOffset.toExponential(2)} of its length from the ${Math.abs(body.rotationPeriodHours).toFixed(5)} its record carries (at most ${dayCeiling} expected).`
|
||||||
|
);
|
||||||
|
spins.push(`${body.id} day ${dayOffset.toExponential(1)}`);
|
||||||
|
}
|
||||||
|
if (body.obliquityDeg !== undefined) {
|
||||||
|
const obliquity = axisFromOrbitDeg(body, rotation, horizons!.epochJd);
|
||||||
|
assertCondition(
|
||||||
|
Math.abs(obliquity - body.obliquityDeg) <= MAX_OBLIQUITY_OFFSET_DEG,
|
||||||
|
`${body.name}'s IAU spin axis is ${obliquity.toFixed(3)} degrees from its orbit's pole, where ${body.id === 'pluto' ? 'its IAU pole' : 'Horizons'} gives an obliquity of ${body.obliquityDeg} (at most ${MAX_OBLIQUITY_OFFSET_DEG} apart expected) — the pole or the sense of W was read wrongly.`
|
||||||
|
);
|
||||||
|
spins.push(`${body.id} tilt ${obliquity.toFixed(3)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (body.kind === 'moon') {
|
if (body.kind === 'moon') {
|
||||||
assertCondition(!!body.parentBodyId && ids.has(body.parentBodyId), `Moon ${body.id} has no valid parentBodyId.`);
|
const parent = bodies.find((candidate) => candidate.id === body.parentBodyId);
|
||||||
|
assertCondition(parent !== undefined, `Moon ${body.id} has no valid parentBodyId.`);
|
||||||
|
const orbitHours = (360 / body.rates.meanMotionDegPerDay) * 24;
|
||||||
|
if (FREELY_SPINNING_MOONS.has(body.id)) {
|
||||||
|
// Hyperion tumbles, and has no period; Nereid turns in 11.594 hours against a 360-day orbit,
|
||||||
|
// and Phoebe in 9.27 against 550 days. A lock here would be the rule below misapplied.
|
||||||
|
assertCondition(
|
||||||
|
body.rotationPeriodHours !== undefined || TUMBLING.has(body.id),
|
||||||
|
`Moon ${body.id} is drawn not turning, and is not known to tumble: its day was measured somewhere, find it.`
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
body.rotationPeriodHours === undefined || Math.abs(body.rotationPeriodHours - orbitHours) > orbitHours * 0.1,
|
||||||
|
`Moon ${body.id} does not keep one face to its planet, yet turns once in ${body.rotationPeriodHours} hours against an orbit of ${orbitHours}.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Every other moon here is tidally locked, and drawn by its orbit and its IAU W: the two
|
||||||
|
// have to agree, or its face turns away from its planet.
|
||||||
|
assertCondition(rotation !== undefined, `Moon ${body.id} is locked but has no W to keep its face to its planet by.`);
|
||||||
|
const ceiling = SUB_PLANET_CEILINGS_DEG[body.id] ?? MAX_SUB_PLANET_LONGITUDE_DEG;
|
||||||
|
const worst = Math.max(...LOCK_DATES_JD.map((jd) => Math.abs(subPlanetLongitudeDeg(body, rotation!, jd))));
|
||||||
|
assertCondition(
|
||||||
|
worst <= ceiling,
|
||||||
|
`Moon ${body.id} turns its face up to ${worst.toFixed(2)} degrees from its planet between AD 1 and 3000 (at most ${ceiling} expected) — its orbit and its W disagree.`
|
||||||
|
);
|
||||||
|
spins.push(`${body.id} faces ${worst.toFixed(2)}`);
|
||||||
|
const axisCeiling = AXIS_FROM_ORBIT_CEILINGS_DEG[body.id] ?? MAX_AXIS_FROM_ORBIT_DEG;
|
||||||
|
const worstAxis = Math.max(...LOCK_DATES_JD.map((jd) => axisFromOrbitDeg(body, rotation!, jd)));
|
||||||
|
assertCondition(
|
||||||
|
worstAxis <= axisCeiling,
|
||||||
|
`Moon ${body.id}'s spin axis leans up to ${worstAxis.toFixed(2)} degrees from its orbit's normal between AD 1 and 3000 (at most ${axisCeiling} expected) — its pole does not go round with its node.`
|
||||||
|
);
|
||||||
|
spins.push(`${body.id} axis ${worstAxis.toFixed(2)}`);
|
||||||
|
}
|
||||||
|
if (body.massRatio !== undefined) {
|
||||||
|
// The pair's barycentre, which the planet's elements place, must lie outside the planet —
|
||||||
|
// that is why the two are drawn going round it — and nearer the planet than the moon.
|
||||||
|
const offsetKm = (body.orbit.semiMajorAxisAu * KM_PER_AU * body.massRatio) / (1 + body.massRatio);
|
||||||
|
assertCondition(
|
||||||
|
body.massRatio > 0 && body.massRatio < 1 && offsetKm > parent!.radiusKm,
|
||||||
|
`${body.name}'s mass ratio ${body.massRatio} puts its barycentre ${offsetKm.toFixed(0)} km from ${parent!.name}'s centre, which is not between its surface, ${parent!.radiusKm} km out, and the moon.`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const planetCount = bodies.filter((body) => body.kind === 'planet').length;
|
const planetCount = bodies.filter((body) => body.kind === 'planet').length;
|
||||||
assertCondition(planetCount === 8, `Expected 8 planets, found ${planetCount}.`);
|
assertCondition(planetCount === 8, `Expected 8 planets, found ${planetCount}.`);
|
||||||
|
const dwarfCount = bodies.filter((body) => body.kind === 'dwarf').length;
|
||||||
|
assertCondition(dwarfCount === 5, `Expected the IAU's 5 dwarf planets, found ${dwarfCount}.`);
|
||||||
|
// Eris keeps one face to Dysnomia, whose orbit takes 15.78590 days (Holler et al. 2021); its light
|
||||||
|
// curve gives 15.771 +/- 0.008 (Bernstein et al. 2023). The SBDB still gives 25.9 hours.
|
||||||
|
const erisDays = (bodies.find((body) => body.id === 'eris')?.rotationPeriodHours ?? NaN) / 24;
|
||||||
|
assertCondition(
|
||||||
|
Math.abs(erisDays / 15.7859 - 1) < 0.002,
|
||||||
|
`Eris turns once in ${erisDays.toFixed(3)} days; it is locked to Dysnomia's 15.786-day orbit — the SBDB's 25.9-hour period, which it flags as possibly 30 per cent wrong, was taken.`
|
||||||
|
);
|
||||||
|
console.log(` mean elements against Horizons, degrees: ${offsets.join(', ')}.`);
|
||||||
|
console.log(` IAU rotation against each record's day and tilt (see MAX_DAY_OFFSET for where each comes from; day as a fraction of it, tilt and a locked moon's face in degrees): ${spins.join(', ')}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateExoplanets(exoplanets: ExoplanetRecord[], starIds: Set<number>): void {
|
/**
|
||||||
|
* The share of planets that must have a star on the map: 6 327 of 6 354 did when the ETL began
|
||||||
|
* adding the hosts the catalogue lacks from the archive's own figures, up from 2 071, and 6 328
|
||||||
|
* once a blank sy_dist gave way to the parallax (mu2 Sco b). The other 26 have neither a distance
|
||||||
|
* nor a parallax in either archive table, so nothing can place them; the floor leaves room
|
||||||
|
* for a few more of those, not for the matching or the additions to stop working.
|
||||||
|
*/
|
||||||
|
const MIN_HOSTED_SHARE = 0.995;
|
||||||
|
/**
|
||||||
|
* Planets whose host only one path places, which the share above has room to lose: mu2 Sco b's
|
||||||
|
* archive rows give a parallax and no distance, and without the parallax it had no star — 6 327
|
||||||
|
* hosted instead of 6 328, and every check passed.
|
||||||
|
*/
|
||||||
|
const REQUIRED_HOSTED_PLANETS = ['mu2 Sco b'];
|
||||||
|
/**
|
||||||
|
* Two hosts' luminosities as the archive gives them, one either side of the Sun's: st_lum −2.821
|
||||||
|
* for Proxima (Ribas et al. 2017 measure 1.51×10⁻³ L☉) and +1.602 for HD 97048. That every
|
||||||
|
* luminosity is positive catches st_lum stored unconverted and nothing else: read as 10^−x or e^x,
|
||||||
|
* Proxima came out 662 or 0.0595 L☉ and every check passed.
|
||||||
|
*/
|
||||||
|
const LUMINOSITY_ANCHORS = [
|
||||||
|
{ planet: 'Proxima Cen b', luminositySolar: 1.51e-3 },
|
||||||
|
{ planet: 'HD 97048 b', luminositySolar: 40 }
|
||||||
|
];
|
||||||
|
const LUMINOSITY_ANCHOR_TOLERANCE = 0.1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The share of planets whose host's radius and temperature the archive gives, which is what
|
||||||
|
* starSurfaceOf draws a host with before deriving one. Measured 6 030 and 6 054 of 6 354 (0.95).
|
||||||
|
* Both come from the composite table only; a column lost on the way leaves every host derived —
|
||||||
|
* Proxima 0.105 R☉ instead of 0.141 — and nothing else fails.
|
||||||
|
*/
|
||||||
|
const MIN_HOST_SURFACE_SHARE = 0.9;
|
||||||
|
/**
|
||||||
|
* How far, in milliarcseconds, a star placed from the archive may sit from its planets' published
|
||||||
|
* position carried from the archive's epoch to the catalogue's. Measured 0.0001 at most, rounding;
|
||||||
|
* carried from J2016 instead, TOI-2406 moves 203 mas and 2 287 archive stars more than 1.
|
||||||
|
*/
|
||||||
|
const MAX_ARCHIVE_EPOCH_OFFSET_MAS = 1;
|
||||||
|
|
||||||
|
function validateExoplanets(exoplanets: ExoplanetRecord[], stars: StarRecord[]): void {
|
||||||
assertCondition(exoplanets.length > 0, 'No exoplanets were produced.');
|
assertCondition(exoplanets.length > 0, 'No exoplanets were produced.');
|
||||||
|
const starsById = new Map(stars.map((star) => [star.id, star]));
|
||||||
|
|
||||||
let crossReferenced = 0;
|
let crossReferenced = 0;
|
||||||
for (const exoplanet of exoplanets) {
|
for (const exoplanet of exoplanets) {
|
||||||
assertCondition(!!exoplanet.name, `Exoplanet ${exoplanet.id} has no name.`);
|
assertCondition(!!exoplanet.name, `Exoplanet ${exoplanet.id} has no name.`);
|
||||||
if (exoplanet.hostStarId !== null) {
|
if (exoplanet.hostStarId !== null) {
|
||||||
assertCondition(starIds.has(exoplanet.hostStarId), `Exoplanet ${exoplanet.id} references unknown star id ${exoplanet.hostStarId}.`);
|
const host = starsById.get(exoplanet.hostStarId);
|
||||||
|
assertCondition(host !== undefined, `Exoplanet ${exoplanet.id} references unknown star id ${exoplanet.hostStarId}.`);
|
||||||
|
// A host known only as "Gaia DR3 2635476908753563008" cannot be found by searching for
|
||||||
|
// TRAPPIST-1; fetchExoplanets names it after its host, and 574 were renamed.
|
||||||
|
assertCondition(!isDesignation(host!), `Exoplanet ${exoplanet.id}'s host is only a designation, ${host!.name}, not named after ${exoplanet.hostStarName}.`);
|
||||||
// The Sun has no exoplanets, so any match to it is a matching failure — historically a
|
// 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
|
// 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.
|
// exactly. Free, permanent tripwire for that whole class of bug.
|
||||||
@@ -177,16 +781,96 @@ function validateExoplanets(exoplanets: ExoplanetRecord[], starIds: Set<number>)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(` ${crossReferenced}/${exoplanets.length} exoplanets cross-referenced to a HYG host star.`);
|
console.log(` ${crossReferenced}/${exoplanets.length} exoplanets have a host star on the map.`);
|
||||||
|
assertCondition(
|
||||||
|
crossReferenced >= exoplanets.length * MIN_HOSTED_SHARE,
|
||||||
|
`Only ${crossReferenced} of ${exoplanets.length} exoplanets have a host star (at least ${MIN_HOSTED_SHARE * 100} % expected) — hosts are no longer being matched or added.`
|
||||||
|
);
|
||||||
|
for (const name of REQUIRED_HOSTED_PLANETS) {
|
||||||
|
const planet = exoplanets.find((candidate) => candidate.name === name);
|
||||||
|
assertCondition(planet?.hostStarId != null, `${name} has no host star — a host the archive places by its parallax alone is no longer placed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const withRadius = exoplanets.filter((exoplanet) => exoplanet.hostStarRadiusSolar !== undefined).length;
|
||||||
|
const withTemperature = exoplanets.filter((exoplanet) => exoplanet.hostStarTemperatureK !== undefined).length;
|
||||||
|
assertCondition(
|
||||||
|
Math.min(withRadius, withTemperature) >= exoplanets.length * MIN_HOST_SURFACE_SHARE,
|
||||||
|
`Only ${withRadius} of ${exoplanets.length} exoplanets carry their host's radius and ${withTemperature} its temperature (at least ${MIN_HOST_SURFACE_SHARE * 100} % expected) — st_rad or st_teff is being lost.`
|
||||||
|
);
|
||||||
|
// Since d94451e st_lum warms the planets of 4 441 hosts and is what their cards print; lost, each
|
||||||
|
// falls back to a derived luminosity, 757 of them more than 1.5 times off it. Measured 6 036.
|
||||||
|
const luminosities = exoplanets.map((exoplanet) => exoplanet.hostStarLuminositySolar).filter((luminosity) => luminosity !== undefined);
|
||||||
|
assertCondition(
|
||||||
|
luminosities.length >= exoplanets.length * MIN_HOST_SURFACE_SHARE,
|
||||||
|
`Only ${luminosities.length} of ${exoplanets.length} exoplanets carry their host's luminosity (at least ${MIN_HOST_SURFACE_SHARE * 100} % expected) — st_lum is being lost.`
|
||||||
|
);
|
||||||
|
// Published as a logarithm, most of them negative: stored unconverted, they would not be.
|
||||||
|
assertCondition(
|
||||||
|
luminosities.every((luminosity) => Number.isFinite(luminosity) && luminosity! > 0),
|
||||||
|
'A host luminosity is not a positive number — st_lum is no longer converted from its logarithm.'
|
||||||
|
);
|
||||||
|
for (const anchor of LUMINOSITY_ANCHORS) {
|
||||||
|
const luminosity = exoplanets.find((exoplanet) => exoplanet.name === anchor.planet)?.hostStarLuminositySolar;
|
||||||
|
assertCondition(
|
||||||
|
luminosity !== undefined && Math.abs(luminosity / anchor.luminositySolar - 1) <= LUMINOSITY_ANCHOR_TOLERANCE,
|
||||||
|
`${anchor.planet}'s host is ${luminosity} L☉, not the ${anchor.luminositySolar} its st_lum gives — st_lum is not being read as the base-10 logarithm it is.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(` ${withRadius}/${exoplanets.length} carry their host's radius, ${withTemperature} its temperature, ${luminosities.length} its luminosity.`);
|
||||||
|
|
||||||
|
let worstOffsetMas = 0;
|
||||||
|
for (const star of stars.filter((candidate) => candidate.source === 'exoplanet-archive')) {
|
||||||
|
const planet = exoplanets.find((candidate) => candidate.hostStarId === star.id)!;
|
||||||
|
const at = propagateProperMotion(planet.hostRaDeg!, planet.hostDecDeg!, planet.hostPmRaMasPerYear ?? 0, planet.hostPmDecMasPerYear ?? 0, CATALOGUE_EPOCH - ARCHIVE_EPOCH);
|
||||||
|
const expected = raDegDecDistanceToXyz(at.raDeg, at.decDeg, 1);
|
||||||
|
const length = Math.hypot(star.x, star.y, star.z);
|
||||||
|
// The chord between the two directions, which unlike an arccosine resolves a milliarcsecond.
|
||||||
|
const chord = Math.hypot(star.x / length - expected.x, star.y / length - expected.y, star.z / length - expected.z);
|
||||||
|
worstOffsetMas = Math.max(worstOffsetMas, (chord * 180 * 3_600_000) / Math.PI);
|
||||||
|
}
|
||||||
|
assertCondition(
|
||||||
|
worstOffsetMas <= MAX_ARCHIVE_EPOCH_OFFSET_MAS,
|
||||||
|
`A star placed from the archive sits ${worstOffsetMas.toFixed(1)} mas from its published position carried to J2000 (at most ${MAX_ARCHIVE_EPOCH_OFFSET_MAS} expected) — it is carried from another epoch.`
|
||||||
|
);
|
||||||
|
console.log(` Stars placed from the archive at most ${worstOffsetMas.toExponential(1)} mas from their published position carried to J2000.`);
|
||||||
|
|
||||||
// How many can be propagated at their real rate rather than as if the host were the Sun.
|
// 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 withPeriod = exoplanets.filter((exoplanet) => exoplanet.periodDays !== undefined).length;
|
||||||
const withHostMass = exoplanets.filter((exoplanet) => exoplanet.hostStarMassSolar !== 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.`);
|
console.log(` ${withPeriod}/${exoplanets.length} have a measured period, ${withHostMass} a host star mass.`);
|
||||||
|
|
||||||
|
// The planets photographed by direct imaging, whose card must not say no image of them exists.
|
||||||
|
// Measured: 101 of 101 flagged in the archive, and not transiting, are in the catalogue. What this
|
||||||
|
// catches is the join by name failing, which would put every one of them back under "no image".
|
||||||
|
const imaged = exoplanets.filter((exoplanet) => exoplanet.imaged).length;
|
||||||
|
assertCondition(imaged >= MIN_IMAGED_EXOPLANETS, `Only ${imaged} exoplanets are marked as imaged (at least ${MIN_IMAGED_EXOPLANETS} expected).`);
|
||||||
|
// And the one the flag is wrong on, which the count cannot see: a 2.68-day transiting hot Jupiter
|
||||||
|
// 0.15 mas from its star, flagged for the companion star a survey imaged beside it. Its record
|
||||||
|
// carries neither a period nor an axis, so no separation check could catch it either.
|
||||||
|
assertCondition(
|
||||||
|
!exoplanets.some((exoplanet) => exoplanet.id === 'WASP-108 b' && exoplanet.imaged),
|
||||||
|
'WASP-108 b is marked as imaged; it transits, and only a companion star beside it was imaged (Bohn et al. 2020).'
|
||||||
|
);
|
||||||
|
console.log(` ${imaged} were imaged directly.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MIN_IMAGED_EXOPLANETS = 95;
|
||||||
|
|
||||||
const UNIT_VECTOR_TOLERANCE = 1e-6;
|
const UNIT_VECTOR_TOLERANCE = 1e-6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Objects the backdrop cannot ship without, two from each of OpenNGC's files: the Andromeda
|
||||||
|
* Galaxy and the Small Magellanic Cloud from NGC.csv, the Large Magellanic Cloud and the
|
||||||
|
* Pleiades from addendum.csv. The addendum went unread for as long as the ETL has existed,
|
||||||
|
* because 463 objects without the brightest deep-sky object in the sky validated cleanly.
|
||||||
|
*/
|
||||||
|
const REQUIRED_DEEP_SKY_IDS = ['NGC0224', 'NGC0292', 'ESO056-115', 'Mel022'];
|
||||||
|
/**
|
||||||
|
* 107 of the 110 Messier objects. OpenNGC types the other three as what they are: M40 a double
|
||||||
|
* star, M73 an asterism and M102 a duplicate of M101, none of them a deep-sky object to draw.
|
||||||
|
*/
|
||||||
|
const MIN_MESSIER_OBJECTS = 107;
|
||||||
|
|
||||||
function validateDeepSky(objects: DeepSkyRecord[]): void {
|
function validateDeepSky(objects: DeepSkyRecord[]): void {
|
||||||
assertCondition(objects.length > 0, 'No deep-sky objects were produced.');
|
assertCondition(objects.length > 0, 'No deep-sky objects were produced.');
|
||||||
|
|
||||||
@@ -217,6 +901,12 @@ function validateDeepSky(objects: DeepSkyRecord[]): void {
|
|||||||
assertCondition(kinds.has(kind), `No deep-sky objects of kind "${kind}" were produced.`);
|
assertCondition(kinds.has(kind), `No deep-sky objects of kind "${kind}" were produced.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const id of REQUIRED_DEEP_SKY_IDS) {
|
||||||
|
assertCondition(ids.has(id), `Deep-sky object ${id} is missing — one of OpenNGC's two files was not read.`);
|
||||||
|
}
|
||||||
|
const messier = new Set(objects.map((object) => object.messier).filter((designation) => designation !== null)).size;
|
||||||
|
assertCondition(messier >= MIN_MESSIER_OBJECTS, `Only ${messier} Messier objects were produced (at least ${MIN_MESSIER_OBJECTS} expected).`);
|
||||||
|
|
||||||
const withDistance = objects.filter((object) => object.distancePc !== null).length;
|
const withDistance = objects.filter((object) => object.distancePc !== null).length;
|
||||||
console.log(` ${withDistance}/${objects.length} deep-sky objects have a derived distance.`);
|
console.log(` ${withDistance}/${objects.length} deep-sky objects have a derived distance.`);
|
||||||
}
|
}
|
||||||
@@ -232,20 +922,21 @@ async function build(): Promise<void> {
|
|||||||
console.log(describeSources());
|
console.log(describeSources());
|
||||||
console.log();
|
console.log();
|
||||||
|
|
||||||
const stars = await fetchStars();
|
const catalogueStars = await fetchStars();
|
||||||
console.log();
|
console.log();
|
||||||
const bodies = await fetchSolarSystem();
|
const { bodies, horizonsOrbits, horizonsTracks } = await fetchSolarSystem();
|
||||||
console.log();
|
console.log();
|
||||||
const exoplanets = await fetchExoplanets(stars);
|
// Adds the hosts the catalogue lacks, so it is this list, not the one above, that is published.
|
||||||
|
const { exoplanets, stars } = await fetchExoplanets(catalogueStars);
|
||||||
console.log();
|
console.log();
|
||||||
const deepSky = await fetchDeepSky();
|
const deepSky = await fetchDeepSky();
|
||||||
console.log();
|
console.log();
|
||||||
|
|
||||||
console.log('Validating output...');
|
console.log('Validating output...');
|
||||||
validateStars(stars);
|
validateStars(stars);
|
||||||
validateMerge(stars);
|
validateMerge(stars, await glieseGaiaDesignations());
|
||||||
validateBodies(bodies);
|
validateBodies(bodies, horizonsOrbits, horizonsTracks);
|
||||||
validateExoplanets(exoplanets, new Set(stars.map((star) => star.id)));
|
validateExoplanets(exoplanets, stars);
|
||||||
validateDeepSky(deepSky);
|
validateDeepSky(deepSky);
|
||||||
|
|
||||||
console.log('\nETL completed successfully:');
|
console.log('\nETL completed successfully:');
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import { fetchTextCached } from './lib/http';
|
|||||||
import { dataPath, ensureDataDir } from './lib/paths';
|
import { dataPath, ensureDataDir } from './lib/paths';
|
||||||
|
|
||||||
const OPENNGC_CSV_URL = 'https://raw.githubusercontent.com/mattiaverga/OpenNGC/master/database_files/NGC.csv';
|
const OPENNGC_CSV_URL = 'https://raw.githubusercontent.com/mattiaverga/OpenNGC/master/database_files/NGC.csv';
|
||||||
|
/**
|
||||||
|
* OpenNGC's second file, with the same columns, for the objects no NGC or IC number covers:
|
||||||
|
* the Pleiades, the Hyades, the Large Magellanic Cloud, the Horsehead, the Coalsack. Without
|
||||||
|
* it the brightest deep-sky object in the sky is missing while the Small Cloud is drawn.
|
||||||
|
*/
|
||||||
|
const OPENNGC_ADDENDUM_CSV_URL = 'https://raw.githubusercontent.com/mattiaverga/OpenNGC/master/database_files/addendum.csv';
|
||||||
/** OpenNGC publishes semicolon-separated files, not comma-separated. */
|
/** OpenNGC publishes semicolon-separated files, not comma-separated. */
|
||||||
const OPENNGC_DELIMITER = ';';
|
const OPENNGC_DELIMITER = ';';
|
||||||
|
|
||||||
@@ -64,8 +70,10 @@ function resolveName(commonName: string | null, messier: string | null, designat
|
|||||||
*/
|
*/
|
||||||
export async function fetchDeepSky(): Promise<DeepSkyRecord[]> {
|
export async function fetchDeepSky(): Promise<DeepSkyRecord[]> {
|
||||||
console.log('Fetching OpenNGC deep-sky catalog...');
|
console.log('Fetching OpenNGC deep-sky catalog...');
|
||||||
const csv = await fetchTextCached(OPENNGC_CSV_URL, 'openngc.csv');
|
const rows = [
|
||||||
const rows = parseCsvObjects(csv, OPENNGC_DELIMITER);
|
...parseCsvObjects(await fetchTextCached(OPENNGC_CSV_URL, 'openngc.csv'), OPENNGC_DELIMITER),
|
||||||
|
...parseCsvObjects(await fetchTextCached(OPENNGC_ADDENDUM_CSV_URL, 'openngc-addendum.csv'), OPENNGC_DELIMITER)
|
||||||
|
];
|
||||||
|
|
||||||
const records: DeepSkyRecord[] = [];
|
const records: DeepSkyRecord[] = [];
|
||||||
let skippedUnclassified = 0;
|
let skippedUnclassified = 0;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { writeFileSync } from 'node:fs';
|
import { writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
import { buildStarNameIndex, resolveHostStarId } from '../../src/app/shared/astro/host-star-matching';
|
import { propagateProperMotion, raDegDecDistanceToXyz } from '../../src/app/shared/astro/coordinates';
|
||||||
|
import { ARCHIVE_EPOCH, archiveDistancePc, archiveStarId, buildStarNameIndex, CATALOGUE_EPOCH, resolveHostStarId } from '../../src/app/shared/astro/host-star-matching';
|
||||||
|
import { temperatureToColorIndex } from '../../src/app/shared/astro/spectral';
|
||||||
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
|
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
|
||||||
|
import { isDesignation } from '../../src/app/shared/models/star-catalog';
|
||||||
import { StarRecord } from '../../src/app/shared/models/star.model';
|
import { StarRecord } from '../../src/app/shared/models/star.model';
|
||||||
import { fetchStars } from './fetchStars';
|
import { fetchStars, writeStarAssets } from './fetchStars';
|
||||||
import { parseCsvObjects, parseOptionalNumber } from './lib/csv';
|
import { parseCsvObjects, parseOptionalNumber } from './lib/csv';
|
||||||
import { fetchTextCached } from './lib/http';
|
import { fetchTextCached } from './lib/http';
|
||||||
import { dataPath, ensureDataDir } from './lib/paths';
|
import { dataPath, ensureDataDir } from './lib/paths';
|
||||||
@@ -41,35 +44,151 @@ const TAP_URL = `${TAP_BASE_URL}?query=${TAP_QUERY}`;
|
|||||||
// silently wrong rather than visibly broken.
|
// silently wrong rather than visibly broken.
|
||||||
const CACHE_FILE = `exoplanet-archive-ps-${createHash('sha1').update(TAP_URL).digest('hex').slice(0, 8)}.csv`;
|
const CACHE_FILE = `exoplanet-archive-ps-${createHash('sha1').update(TAP_URL).digest('hex').slice(0, 8)}.csv`;
|
||||||
|
|
||||||
|
// The planets the archive flags as detected by imaging (`ima_flag`): 101 of them in September 2026,
|
||||||
|
// HR 8799's four and 51 Eri b among them, and bet Pic c and eps Ind A b, found by radial velocity
|
||||||
|
// and imaged since. Asked for on its own, so adding it did not refetch the table above and move
|
||||||
|
// every other planet to a newer snapshot. A transiting planet is left out: the one flagged,
|
||||||
|
// WASP-108 b, takes its flag from Bohn et al. 2020, a VLT/SPHERE survey of transiting planets' host
|
||||||
|
// stars that imaged a 0.35 solar-mass companion 0.124" from its star. The planet goes round in 2.68
|
||||||
|
// days, 0.04 AU out, 0.15 mas at its 259 pc, and no imager has resolved it.
|
||||||
|
const IMAGED_URL = `${TAP_BASE_URL}?query=select+pl_name+from+ps+where+default_flag=1+and+ima_flag=1+and+tran_flag=0+order+by+pl_name&format=csv`;
|
||||||
|
const IMAGED_CACHE_FILE = `exoplanet-archive-imaged-${createHash('sha1').update(IMAGED_URL).digest('hex').slice(0, 8)}.csv`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The host's columns from the Planetary Systems Composite table, for the cells a planet's
|
||||||
|
* default row leaves blank and for the columns that query does not ask for.
|
||||||
|
*
|
||||||
|
* The default rows are one reference each, which is what keeps a planet's orbit coherent: its
|
||||||
|
* period, semi-major axis, eccentricity and periastron come from one fit. A composite row takes
|
||||||
|
* each column from wherever it is best measured, so an orbit read from it could pair one paper's
|
||||||
|
* eccentricity with another's argument of periastron; none of its orbital columns are asked for.
|
||||||
|
* Its system columns equal the default rows' wherever both are given (6 225 distances, 6 352
|
||||||
|
* positions, none different). What it adds is 100 of the 127 distances the default rows leave blank,
|
||||||
|
* TRAPPIST-1's seven among them, 870 host masses, and the parallax, photometry and stellar
|
||||||
|
* parameters below — each of which may come from a different reference.
|
||||||
|
*/
|
||||||
|
const COMPOSITE_COLUMNS = [
|
||||||
|
'pl_name',
|
||||||
|
'ra',
|
||||||
|
'dec',
|
||||||
|
'sy_dist',
|
||||||
|
'sy_plx',
|
||||||
|
'sy_pmra',
|
||||||
|
'sy_pmdec',
|
||||||
|
'sy_bmag',
|
||||||
|
'sy_vmag',
|
||||||
|
'sy_gaiamag',
|
||||||
|
'st_spectype',
|
||||||
|
'st_teff',
|
||||||
|
'st_rad',
|
||||||
|
'st_mass',
|
||||||
|
'st_lum'
|
||||||
|
].join(',');
|
||||||
|
// pl_name is unique here too, one row per planet.
|
||||||
|
const COMPOSITE_URL = `${TAP_BASE_URL}?query=select+${COMPOSITE_COLUMNS}+from+pscomppars+order+by+pl_name&format=csv`;
|
||||||
|
const COMPOSITE_CACHE_FILE = `exoplanet-archive-pscomppars-${createHash('sha1').update(COMPOSITE_URL).digest('hex').slice(0, 8)}.csv`;
|
||||||
|
/**
|
||||||
|
* The same table's distance errors, for the stars placed from the archive. A query of its own,
|
||||||
|
* cached apart, so that asking for them did not refetch the columns above: the archive changes
|
||||||
|
* daily, and a new answer would have moved every figure the catalogue was checked against.
|
||||||
|
*/
|
||||||
|
const DISTANCE_ERRORS_URL = `${TAP_BASE_URL}?query=select+pl_name,sy_disterr1,sy_disterr2+from+pscomppars+order+by+pl_name&format=csv`;
|
||||||
|
const DISTANCE_ERRORS_CACHE_FILE = `exoplanet-archive-disterr-${createHash('sha1').update(DISTANCE_ERRORS_URL).digest('hex').slice(0, 8)}.csv`;
|
||||||
|
|
||||||
|
const ARCHIVE_SOURCE = 'exoplanet-archive';
|
||||||
|
/**
|
||||||
|
* The archive's positions are at Gaia DR2's epoch, J2015.5, not the catalogue's J2000 (see
|
||||||
|
* `ARCHIVE_EPOCH`): of the 746 matched hosts moving over 100 mas a year, 741 sit nearer their star
|
||||||
|
* once carried back (a median 0.11″ from it, against 3.47″ as published). The matcher tries both
|
||||||
|
* epochs; a star placed from the archive has to pick one.
|
||||||
|
*/
|
||||||
|
const ARCHIVE_TO_CATALOGUE_YEARS = CATALOGUE_EPOCH - ARCHIVE_EPOCH;
|
||||||
|
/** As in `fetchStars`: faint, for a host the archive gives neither a V nor a G magnitude. */
|
||||||
|
const UNKNOWN_MAGNITUDE = 15;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads confirmed exoplanets from the NASA Exoplanet Archive (`Planetary Systems` TAP
|
* Downloads confirmed exoplanets from the NASA Exoplanet Archive (`Planetary Systems` TAP
|
||||||
* table), cross-references each host star to the HYG index, and writes `exoplanets.json`.
|
* table), cross-references each host star to the star catalogue, adds a star from the archive's
|
||||||
|
* own figures for each host the catalogue lacks but the archive places, and writes
|
||||||
|
* `exoplanets.json` together with the star assets, which those additions change. Returns both.
|
||||||
*/
|
*/
|
||||||
export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRecord[]> {
|
export async function fetchExoplanets(stars?: StarRecord[]): Promise<{ exoplanets: ExoplanetRecord[]; stars: StarRecord[] }> {
|
||||||
console.log('Fetching confirmed exoplanets from the NASA Exoplanet Archive...');
|
console.log('Fetching confirmed exoplanets from the NASA Exoplanet Archive...');
|
||||||
const knownStars = stars ?? (await fetchStars());
|
const knownStars = stars ?? (await fetchStars());
|
||||||
const nameIndex = buildStarNameIndex(knownStars);
|
const nameIndex = buildStarNameIndex(knownStars);
|
||||||
|
const knownById = new Map(knownStars.map((star) => [star.id, star]));
|
||||||
|
|
||||||
const csv = await fetchTextCached(TAP_URL, CACHE_FILE);
|
const csv = await fetchTextCached(TAP_URL, CACHE_FILE);
|
||||||
const rows = parseCsvObjects(csv);
|
const rows = parseCsvObjects(csv);
|
||||||
|
const composite = new Map(parseCsvObjects(await fetchTextCached(COMPOSITE_URL, COMPOSITE_CACHE_FILE)).map((row) => [row['pl_name'], row]));
|
||||||
|
const distanceErrors = new Map(parseCsvObjects(await fetchTextCached(DISTANCE_ERRORS_URL, DISTANCE_ERRORS_CACHE_FILE)).map((row) => [row['pl_name'], row]));
|
||||||
|
const imaged = new Set(parseCsvObjects(await fetchTextCached(IMAGED_URL, IMAGED_CACHE_FILE)).map((row) => row['pl_name']));
|
||||||
|
|
||||||
let matched = 0;
|
let matched = 0;
|
||||||
|
// Catalogue stars known only by their Gaia designation, which take the archive's host name —
|
||||||
|
// the only way "TRAPPIST-1" or "Teegarden's Star" can be found by search.
|
||||||
|
const renamed = new Map<number, string>();
|
||||||
|
const archiveStars = new Map<string, StarRecord>();
|
||||||
|
const archiveIds = new Set<number>();
|
||||||
|
const bands = { V: 0, G: 0, none: 0 };
|
||||||
const exoplanets: ExoplanetRecord[] = rows.map((row, index) => {
|
const exoplanets: ExoplanetRecord[] = rows.map((row, index) => {
|
||||||
|
const compositeRow = composite.get(row['pl_name']);
|
||||||
// `parseOptionalNumber`, not `Number`: a blank cell would otherwise become 0, which is a
|
// `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.
|
// finite, plausible-looking coordinate rather than the "not measured" it actually means.
|
||||||
const raDeg = parseOptionalNumber(row['ra']) ?? Number.NaN;
|
const host = (column: string) => parseOptionalNumber(row[column] || compositeRow?.[column]);
|
||||||
const decDeg = parseOptionalNumber(row['dec']) ?? Number.NaN;
|
const raDeg = host('ra') ?? Number.NaN;
|
||||||
const distancePc = parseOptionalNumber(row['sy_dist']) ?? Number.NaN;
|
const decDeg = host('dec') ?? Number.NaN;
|
||||||
const pmRaMasPerYear = parseOptionalNumber(row['sy_pmra']);
|
const distancePc = archiveDistancePc(host('sy_dist'), host('sy_plx'));
|
||||||
const pmDecMasPerYear = parseOptionalNumber(row['sy_pmdec']);
|
const pmRaMasPerYear = host('sy_pmra');
|
||||||
|
const pmDecMasPerYear = host('sy_pmdec');
|
||||||
|
|
||||||
const hostStarId = resolveHostStarId(
|
let hostStarId = resolveHostStarId(
|
||||||
{ hostname: row['hostname'], raDeg, decDeg, distancePc, pmRaMasPerYear, pmDecMasPerYear },
|
{ hostname: row['hostname'], raDeg, decDeg, distancePc, pmRaMasPerYear, pmDecMasPerYear, parallaxMas: host('sy_plx') },
|
||||||
knownStars,
|
knownStars,
|
||||||
nameIndex
|
nameIndex
|
||||||
);
|
);
|
||||||
if (hostStarId !== null) {
|
if (hostStarId !== null) {
|
||||||
matched++;
|
matched++;
|
||||||
|
const star = knownById.get(hostStarId);
|
||||||
|
if (star?.source === 'gaia' && isDesignation(star) && !renamed.has(hostStarId)) {
|
||||||
|
renamed.set(hostStarId, row['hostname']);
|
||||||
|
}
|
||||||
|
} else if ([raDeg, decDeg, distancePc].every(Number.isFinite) && distancePc > 0) {
|
||||||
|
let archiveStar = archiveStars.get(row['hostname']);
|
||||||
|
if (!archiveStar) {
|
||||||
|
// Carried back from the archive's epoch like any Gaia row, and placed at `sy_dist`.
|
||||||
|
const j2000 = propagateProperMotion(raDeg, decDeg, pmRaMasPerYear ?? 0, pmDecMasPerYear ?? 0, ARCHIVE_TO_CATALOGUE_YEARS);
|
||||||
|
// V where the archive has it, as HYG's stars are; else Gaia's G, the band the catalogue's
|
||||||
|
// Gaia stars are already in.
|
||||||
|
const v = host('sy_vmag');
|
||||||
|
const g = host('sy_gaiamag');
|
||||||
|
const b = host('sy_bmag');
|
||||||
|
const temperatureK = host('st_teff');
|
||||||
|
const band = v !== undefined ? 'V' : g !== undefined ? 'G' : undefined;
|
||||||
|
bands[band ?? 'none']++;
|
||||||
|
// B-V where the archive has both magnitudes, else the effective temperature's; with
|
||||||
|
// neither, null leaves the colour to the spectral type, as for any other star.
|
||||||
|
const colorIndex = b !== undefined && v !== undefined ? b - v : temperatureK !== undefined ? temperatureToColorIndex(temperatureK) : null;
|
||||||
|
// The archive gives the distance's error as two one-sided ones; their mean, relative.
|
||||||
|
const errors = distanceErrors.get(row['pl_name']);
|
||||||
|
const [above, below] = [parseOptionalNumber(errors?.['sy_disterr1']), parseOptionalNumber(errors?.['sy_disterr2'])];
|
||||||
|
archiveStar = {
|
||||||
|
id: archiveStarId(row['hostname'], archiveIds),
|
||||||
|
name: row['hostname'],
|
||||||
|
...raDegDecDistanceToXyz(j2000.raDeg, j2000.decDeg, distancePc),
|
||||||
|
magnitude: v ?? g ?? UNKNOWN_MAGNITUDE,
|
||||||
|
...(band === undefined ? {} : { magnitudeBand: band }),
|
||||||
|
spectralType: compositeRow?.['st_spectype'] || 'Unknown',
|
||||||
|
colorIndex,
|
||||||
|
...(colorIndex === null ? {} : { colorSystem: 'B-V' as const }),
|
||||||
|
...(colorIndex !== null && (b === undefined || v === undefined) ? { colorFromTemperature: true } : {}),
|
||||||
|
...(above === undefined || below === undefined ? {} : { distanceError: (Math.abs(above) + Math.abs(below)) / 2 / distancePc }),
|
||||||
|
source: ARCHIVE_SOURCE
|
||||||
|
};
|
||||||
|
archiveStars.set(row['hostname'], archiveStar);
|
||||||
|
archiveIds.add(archiveStar.id);
|
||||||
|
}
|
||||||
|
hostStarId = archiveStar.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -80,16 +199,21 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
|
|||||||
radiusEarth: parseOptionalNumber(row['pl_rade']),
|
radiusEarth: parseOptionalNumber(row['pl_rade']),
|
||||||
massEarth: parseOptionalNumber(row['pl_bmasse']),
|
massEarth: parseOptionalNumber(row['pl_bmasse']),
|
||||||
discoveryYear: parseOptionalNumber(row['disc_year']),
|
discoveryYear: parseOptionalNumber(row['disc_year']),
|
||||||
|
imaged: imaged.has(row['pl_name']) || undefined,
|
||||||
// The period was already being downloaded and thrown away. With the semi-major axis it
|
// 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
|
// 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.
|
// propagating a planet at its real rate and pretending every host is the Sun.
|
||||||
periodDays: parseOptionalNumber(row['pl_orbper']),
|
periodDays: parseOptionalNumber(row['pl_orbper']),
|
||||||
hostStarMassSolar: parseOptionalNumber(row['st_mass']),
|
hostStarMassSolar: host('st_mass'),
|
||||||
|
hostStarRadiusSolar: host('st_rad'),
|
||||||
|
hostStarTemperatureK: host('st_teff'),
|
||||||
|
// Published as log10(L/L☉).
|
||||||
|
hostStarLuminositySolar: ((logLuminosity) => (logLuminosity === undefined ? undefined : 10 ** logLuminosity))(host('st_lum')),
|
||||||
// Kept so the cross-reference can be redone without the archive; see the record's own
|
// 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.
|
// documentation. Undefined rather than NaN, which JSON cannot represent.
|
||||||
hostRaDeg: parseOptionalNumber(row['ra']),
|
hostRaDeg: host('ra'),
|
||||||
hostDecDeg: parseOptionalNumber(row['dec']),
|
hostDecDeg: host('dec'),
|
||||||
hostDistancePc: parseOptionalNumber(row['sy_dist']),
|
hostDistancePc: host('sy_dist'),
|
||||||
hostPmRaMasPerYear: pmRaMasPerYear,
|
hostPmRaMasPerYear: pmRaMasPerYear,
|
||||||
hostPmDecMasPerYear: pmDecMasPerYear,
|
hostPmDecMasPerYear: pmDecMasPerYear,
|
||||||
orbit: {
|
orbit: {
|
||||||
@@ -101,10 +225,20 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Appended after the catalogue, whose ids all sit below ARCHIVE_ID_BASE, and sorted, so the list
|
||||||
|
// stays in the id order `fetchStars` sorted it into.
|
||||||
|
const added = [...archiveStars.values()].sort((a, b) => a.id - b.id);
|
||||||
|
const allStars = [...knownStars.map((star) => (renamed.has(star.id) ? { ...star, name: renamed.get(star.id)! } : star)), ...added];
|
||||||
|
writeStarAssets(allStars);
|
||||||
|
|
||||||
ensureDataDir();
|
ensureDataDir();
|
||||||
writeFileSync(dataPath('exoplanets.json'), JSON.stringify(exoplanets));
|
writeFileSync(dataPath('exoplanets.json'), JSON.stringify(exoplanets));
|
||||||
console.log(` wrote ${exoplanets.length} exoplanets (${matched} cross-referenced to a HYG host star).`);
|
console.log(
|
||||||
return exoplanets;
|
` wrote ${exoplanets.length} exoplanets: ${matched} on a catalogue star (${renamed.size} Gaia designations named after their host), ` +
|
||||||
|
`${exoplanets.filter((exoplanet) => exoplanet.hostStarId !== null).length - matched} on ${added.length} stars added from the archive ` +
|
||||||
|
`(${added.filter((star) => Math.hypot(star.x, star.y, star.z) <= 250).length} within 250 pc; magnitude in V for ${bands.V}, in G for ${bands.G}, none for ${bands.none}).`
|
||||||
|
);
|
||||||
|
return { exoplanets, stars: allStars };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { writeFileSync } from 'node:fs';
|
import { writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
import { BodyRecord } from '../../src/app/shared/models/body.model';
|
import { BodyRecord, OrbitalElements } from '../../src/app/shared/models/body.model';
|
||||||
import { SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
import { SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
||||||
import { fetchHorizonsBody } from './lib/horizons';
|
import { fetchHorizonsBody, fetchHorizonsTrack, TRACK_START_YEAR, TRACK_STOP_YEAR, TrackPoint } from './lib/horizons';
|
||||||
|
import { eclipticToEquatorial, laplacePlaneToEquatorial } from '../../src/app/shared/astro/coordinates';
|
||||||
|
import { meanElementsAt, positionAtEpoch } from '../../src/app/shared/astro/kepler';
|
||||||
|
import { MeanOrbit, parsePlanetMeanElements, parseSatelliteMeanElements, parseSmallBodyElements } from '../../src/app/shared/astro/mean-elements';
|
||||||
|
import { fetchPlanetMeanElementsText, fetchSatelliteMeanElementsHtml, fetchSmallBodyAnswer } from './lib/mean-elements';
|
||||||
|
import { MIN_PERIODIC_TERM_DEG, orbitalTermsOfPrimeMeridian, parsePckRotationalElements, SUN_ROTATIONAL_ELEMENTS } from '../../src/app/shared/astro/rotational-elements';
|
||||||
|
import { fetchPckText } from './lib/pck';
|
||||||
|
import { lockedToOrbit } from './lib/locked-spin';
|
||||||
import { dataPath, ensureDataDir } from './lib/paths';
|
import { dataPath, ensureDataDir } from './lib/paths';
|
||||||
|
|
||||||
|
const HOURS_PER_DAY = 24;
|
||||||
|
const DAYS_PER_JULIAN_YEAR = 365.25;
|
||||||
|
|
||||||
interface BodySpec {
|
interface BodySpec {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -12,8 +22,100 @@ interface BodySpec {
|
|||||||
horizonsCommand: string;
|
horizonsCommand: string;
|
||||||
center: string;
|
center: string;
|
||||||
parentBodyId?: string;
|
parentBodyId?: string;
|
||||||
|
/**
|
||||||
|
* Obliquity to orbit, in degrees, where the Horizons page states none. Pluto's is from the IAU
|
||||||
|
* WGCCRE 2015 pole (RA 132.99, Dec -6.16), 119.6 degrees: past 90, so it turns retrograde.
|
||||||
|
*/
|
||||||
|
obliquityDeg?: number;
|
||||||
|
/** The periapsis turns backwards; see `parseSatelliteMeanElements`. */
|
||||||
|
apsidesRegress?: boolean;
|
||||||
|
/** The pole of the planet's equator, where JPL gives its moons against that plane. */
|
||||||
|
equatorPole?: { raDeg: number; decDeg: number };
|
||||||
|
/** The Small-Body Database's name for a dwarf planet past Standish's tables: its orbit comes from there. */
|
||||||
|
sbdb?: string;
|
||||||
|
/** A measured mean radius, in km, for a body neither Horizons nor the SBDB gives one for. */
|
||||||
|
radiusKm?: number;
|
||||||
|
/** A triaxial body's semi-axes, in km, largest first, where its card should give its shape; see `BodyRecord.semiAxesKm`. */
|
||||||
|
semiAxesKm?: [number, number, number];
|
||||||
|
/** A measured sidereal day, in hours, where a later measurement overturns the one its source gives. */
|
||||||
|
rotationPeriodHours?: number;
|
||||||
|
/** The eccentricity for the card, where the row the orbit is drawn from gives an outdated one; see `BodyRecord.measuredEccentricity`. */
|
||||||
|
measuredEccentricity?: number;
|
||||||
|
/** A moon that does not keep one face to its planet: its page's own spin, or none, is kept. */
|
||||||
|
spinsFreely?: boolean;
|
||||||
|
/** A moon heavy enough to move its planet round their barycentre visibly; see `BodyRecord.massRatio`. */
|
||||||
|
barycentric?: boolean;
|
||||||
|
/**
|
||||||
|
* Corrections to a row of the satellite table, each where the row disagrees with JPL's own
|
||||||
|
* Horizons ephemeris and the reason is known; see the specs that carry them.
|
||||||
|
*/
|
||||||
|
nodeOffsetDeg?: number;
|
||||||
|
epochJd?: number;
|
||||||
|
periodDays?: number;
|
||||||
|
/**
|
||||||
|
* The node's period, in Julian years, where the row's is out of date. The archived table the rows
|
||||||
|
* are read from (see `fetchSatelliteMeanElementsHtml`) has older node periods for four moons than
|
||||||
|
* JPL's current one (ssd.jpl.nasa.gov/sats/elem), and Horizons and the IAU agree with the current
|
||||||
|
* ones: Miranda 17.787 years there (URA182) against the row's 17.727, Ganymede 137.812 (JUP365)
|
||||||
|
* against 132.654, Callisto 577.264 against 338.82, Titan 687.370 (SAT441) against 704.60. Fitted
|
||||||
|
* to Horizons' osculating elements on Uranus's equator over 1601-2399, Miranda's node turns
|
||||||
|
* 2023.97 degrees a century (rms 0.04): the current table's 2023.95, the IAU's U11 2024.22, the
|
||||||
|
* row's 2030.80. On the row's rate Miranda's drawn orbit was 2.0 degrees from Horizons' at 1601
|
||||||
|
* and 2.1 at 2399, and the axis `lockedToOrbit` turned after it 2.4 at 1601; on this one, at most 0.12 and 0.34 over 1601-2399.
|
||||||
|
*
|
||||||
|
* The row's periapsis turns at its argument's rate from that node, and it is the longitude, node
|
||||||
|
* plus argument, the row gives the rate of: Callisto's turns 68.7 degrees a century in the row and
|
||||||
|
* 67.2 in the current table, where their arguments turn at 175.0 and 129.5. So the argument takes
|
||||||
|
* up what the node's rate gives: on the new node and the row's argument, Callisto's periapsis
|
||||||
|
* moved 44 degrees by 2100 and Callisto strayed 0.71 degrees from Horizons over 1950-2100 (0.19
|
||||||
|
* before); keeping the row's longitude, 0.08.
|
||||||
|
*/
|
||||||
|
nodePeriodYears?: number;
|
||||||
|
/**
|
||||||
|
* The terms of the IAU's W that are this locked moon's motion along its orbit, which its row has
|
||||||
|
* no column for: W's quadratic, and the term whose angle turns at `angleRateDegPerCentury`, if
|
||||||
|
* given. See `orbitalTermsOfPrimeMeridian`.
|
||||||
|
*/
|
||||||
|
orbitFromW?: { angleRateDegPerCentury?: number };
|
||||||
|
/** A locked moon whose pole is carried round with its orbit's, as Iapetus's; see `lockedToOrbit`. */
|
||||||
|
poleFollowsOrbit?: boolean;
|
||||||
|
/**
|
||||||
|
* Days between the Horizons positions the orbit is checked against from 1950 to 2100; 2 unless
|
||||||
|
* the error changes faster than that. Nereid, at an eccentricity of 0.75, sweeps through its
|
||||||
|
* periapsis, where the mean ellipse is furthest out, in days; Hyperion, on a row whose
|
||||||
|
* eccentricity is a quarter of its real one, peaks within a day too (22.23 degrees sampled daily
|
||||||
|
* where every other day gave 22.14).
|
||||||
|
*/
|
||||||
|
trackStepDays?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** S5 in pck00011.tpc, 316.45 + 506.2 T: the libration of Mimas and Tethys in their 4:2 resonance. */
|
||||||
|
const MIMAS_TETHYS_LIBRATION = { angleRateDegPerCentury: 506.2 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mimas's node period, 0.986 years in both JPL's tables, is given to three figures: anywhere from
|
||||||
|
* 36 493 to 36 530 degrees a century. It takes the IAU's S3, 36 505.5, which a fit to Horizons'
|
||||||
|
* osculating elements on Saturn's equator over 1750-2249, 36 506.7, is 1.2 from; the row's figure,
|
||||||
|
* 36 511.2, is 4.5.
|
||||||
|
*/
|
||||||
|
const MIMAS_NODE_PERIOD_YEARS = 36000 / 36505.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The poles of the equators JPL refers Uranus's and Pluto's moons to, from the IAU WGCCRE 2015
|
||||||
|
* report, each taken at the end the table's inclinations are measured from (Titania 0.079
|
||||||
|
* degrees, Charon 0.080): the end the moons go round anticlockwise. For Pluto that is the pole
|
||||||
|
* the IAU gives, 132.993 / -6.163, which for dwarf planets follows the right-hand rule. For
|
||||||
|
* Uranus the IAU gives the other end, 257.311 / -15.175, named north because it lies on the
|
||||||
|
* ecliptic's north side; the table measures inclinations from 77.311 / 15.175 but counts its
|
||||||
|
* nodes from where the equator rises through the ICRF equator going round the IAU's pole, which
|
||||||
|
* is 180 degrees from where it rises going round this one: hence Uranus's moons' 180-degree node
|
||||||
|
* offset. Read with this pole and no offset, Ariel was 180 degrees from Horizons at every date
|
||||||
|
* from 1980 to 2100; read against the IAU's pole, anywhere from 1 to 179.
|
||||||
|
*/
|
||||||
|
const URANUS_EQUATOR_POLE = { raDeg: 77.311, decDeg: 15.175 };
|
||||||
|
const PLUTO_EQUATOR_POLE = { raDeg: 132.993, decDeg: -6.163 };
|
||||||
|
const URANUS_MOON = { kind: 'moon', center: '500@799', parentBodyId: 'uranus', equatorPole: URANUS_EQUATOR_POLE, nodeOffsetDeg: 180 } as const;
|
||||||
|
|
||||||
// Sun-centered planets/dwarf, then their major moons (planetocentric elements).
|
// Sun-centered planets/dwarf, then their major moons (planetocentric elements).
|
||||||
const BODY_SPECS: BodySpec[] = [
|
const BODY_SPECS: BodySpec[] = [
|
||||||
{ id: 'mercury', name: 'Mercury', kind: 'planet', horizonsCommand: '199', center: '500@10' },
|
{ id: 'mercury', name: 'Mercury', kind: 'planet', horizonsCommand: '199', center: '500@10' },
|
||||||
@@ -24,26 +126,96 @@ const BODY_SPECS: BodySpec[] = [
|
|||||||
{ id: 'saturn', name: 'Saturn', kind: 'planet', horizonsCommand: '699', center: '500@10' },
|
{ id: 'saturn', name: 'Saturn', kind: 'planet', horizonsCommand: '699', center: '500@10' },
|
||||||
{ id: 'uranus', name: 'Uranus', kind: 'planet', horizonsCommand: '799', center: '500@10' },
|
{ id: 'uranus', name: 'Uranus', kind: 'planet', horizonsCommand: '799', center: '500@10' },
|
||||||
{ id: 'neptune', name: 'Neptune', kind: 'planet', horizonsCommand: '899', center: '500@10' },
|
{ id: 'neptune', name: 'Neptune', kind: 'planet', horizonsCommand: '899', center: '500@10' },
|
||||||
{ id: 'pluto', name: 'Pluto', kind: 'dwarf', horizonsCommand: '999', center: '500@10' },
|
{ id: 'pluto', name: 'Pluto', kind: 'dwarf', horizonsCommand: '999', center: '500@10', obliquityDeg: 119.6 },
|
||||||
|
{ id: 'ceres', name: 'Ceres', kind: 'dwarf', horizonsCommand: '1;', center: '500@10', sbdb: 'Ceres' },
|
||||||
|
// Eris, Haumea and Makemake have no radius in the SBDB, the Horizons pages ("RAD= n.a.") or the
|
||||||
|
// IAU WGCCRE 2015 report, so each carries its stellar-occultation measurement. Eris: 1163 km,
|
||||||
|
// Sicardy et al. 2011 (Nature 478, 493). Haumea is triaxial, 1161 x 852 x 513 km, Ortiz et al.
|
||||||
|
// 2017 (Nature 550, 219); drawn as a sphere, at the radius of the sphere of the same volume.
|
||||||
|
// Makemake: 1434 km across its equator and 1422 across its projected pole, Brown 2013 (ApJ 767,
|
||||||
|
// L7); the same mean.
|
||||||
|
//
|
||||||
|
// Eris's day is not the SBDB's 25.9 hours, a light curve of partial coverage (Roe et al. 2008) the
|
||||||
|
// SBDB itself flags as "may be wrong by 30 percent or so": it turns once in 15.771 +/- 0.008 days
|
||||||
|
// (Bernstein et al. 2023, PSJ 4, 115), locked to Dysnomia's 15.786-day orbit (Szakáts et al.
|
||||||
|
// 2023, A&A 669, L3). On the SBDB's figure it turned 14.6 times too fast.
|
||||||
|
//
|
||||||
|
// Makemake's day, the SBDB's 22.83 hours, carries the same flag and is not settled either: it is
|
||||||
|
// the double-peaked reading Hromakina et al. 2019 (A&A 625, A46) give as "possible" of a light
|
||||||
|
// curve that repeats every 11.4 hours. Kiss et al. 2024 (ApJL, arXiv:2410.22544) find that 11.40
|
||||||
|
// +/- 0.08 hour single peak again with TESS and Gaia, cannot confirm the 22.8, and take 11.4 as
|
||||||
|
// their default. Neither overturns the other; the SBDB's 22.83 is kept, and may be twice the day.
|
||||||
|
{ id: 'eris', name: 'Eris', kind: 'dwarf', horizonsCommand: '136199;', center: '500@10', sbdb: 'Eris', radiusKm: 1163, rotationPeriodHours: 15.771 * 24 },
|
||||||
|
{ id: 'haumea', name: 'Haumea', kind: 'dwarf', horizonsCommand: '136108;', center: '500@10', sbdb: 'Haumea', radiusKm: 797.6, semiAxesKm: [1161, 852, 513] },
|
||||||
|
{ id: 'makemake', name: 'Makemake', kind: 'dwarf', horizonsCommand: '136472;', center: '500@10', sbdb: 'Makemake', radiusKm: 715 },
|
||||||
{ id: 'moon', name: 'Moon', kind: 'moon', horizonsCommand: '301', center: '500@399', parentBodyId: 'earth' },
|
{ id: 'moon', name: 'Moon', kind: 'moon', horizonsCommand: '301', center: '500@399', parentBodyId: 'earth' },
|
||||||
{ id: 'phobos', name: 'Phobos', kind: 'moon', horizonsCommand: '401', center: '500@499', parentBodyId: 'mars' },
|
{ id: 'phobos', name: 'Phobos', kind: 'moon', horizonsCommand: '401', center: '500@499', parentBodyId: 'mars', orbitFromW: {} },
|
||||||
{ id: 'deimos', name: 'Deimos', kind: 'moon', horizonsCommand: '402', center: '500@499', parentBodyId: 'mars' },
|
{ id: 'deimos', name: 'Deimos', kind: 'moon', horizonsCommand: '402', center: '500@499', parentBodyId: 'mars' },
|
||||||
{ id: 'io', name: 'Io', kind: 'moon', horizonsCommand: '501', center: '500@599', parentBodyId: 'jupiter' },
|
{ id: 'io', name: 'Io', kind: 'moon', horizonsCommand: '501', center: '500@599', parentBodyId: 'jupiter', apsidesRegress: true },
|
||||||
{ id: 'europa', name: 'Europa', kind: 'moon', horizonsCommand: '502', center: '500@599', parentBodyId: 'jupiter' },
|
{ id: 'europa', name: 'Europa', kind: 'moon', horizonsCommand: '502', center: '500@599', parentBodyId: 'jupiter', apsidesRegress: true },
|
||||||
{ id: 'ganymede', name: 'Ganymede', kind: 'moon', horizonsCommand: '503', center: '500@599', parentBodyId: 'jupiter' },
|
{ id: 'ganymede', name: 'Ganymede', kind: 'moon', horizonsCommand: '503', center: '500@599', parentBodyId: 'jupiter', nodePeriodYears: 137.812 },
|
||||||
{ id: 'callisto', name: 'Callisto', kind: 'moon', horizonsCommand: '504', center: '500@599', parentBodyId: 'jupiter' },
|
{ id: 'callisto', name: 'Callisto', kind: 'moon', horizonsCommand: '504', center: '500@599', parentBodyId: 'jupiter', nodePeriodYears: 577.264 },
|
||||||
{ id: 'titan', name: 'Titan', kind: 'moon', horizonsCommand: '606', center: '500@699', parentBodyId: 'saturn' },
|
{ id: 'mimas', name: 'Mimas', kind: 'moon', horizonsCommand: '601', center: '500@699', parentBodyId: 'saturn', orbitFromW: MIMAS_TETHYS_LIBRATION, nodePeriodYears: MIMAS_NODE_PERIOD_YEARS },
|
||||||
{ id: 'triton', name: 'Triton', kind: 'moon', horizonsCommand: '801', center: '500@899', parentBodyId: 'neptune' }
|
{ id: 'enceladus', name: 'Enceladus', kind: 'moon', horizonsCommand: '602', center: '500@699', parentBodyId: 'saturn' },
|
||||||
|
{ id: 'tethys', name: 'Tethys', kind: 'moon', horizonsCommand: '603', center: '500@699', parentBodyId: 'saturn', orbitFromW: MIMAS_TETHYS_LIBRATION },
|
||||||
|
{ id: 'dione', name: 'Dione', kind: 'moon', horizonsCommand: '604', center: '500@699', parentBodyId: 'saturn' },
|
||||||
|
{ id: 'rhea', name: 'Rhea', kind: 'moon', horizonsCommand: '605', center: '500@699', parentBodyId: 'saturn' },
|
||||||
|
{ id: 'titan', name: 'Titan', kind: 'moon', horizonsCommand: '606', center: '500@699', parentBodyId: 'saturn', nodePeriodYears: 687.37 },
|
||||||
|
// Hyperion tumbles ("Rotational period = Chaotic") and Phoebe, captured, turns in 9.27 hours.
|
||||||
|
// Hyperion's eccentricity is 0.105 in JPL's current table (ssd.jpl.nasa.gov/sats/elem, SAT441).
|
||||||
|
{ id: 'hyperion', name: 'Hyperion', kind: 'moon', horizonsCommand: '607', center: '500@699', parentBodyId: 'saturn', spinsFreely: true, measuredEccentricity: 0.105, trackStepDays: 1 },
|
||||||
|
{ id: 'iapetus', name: 'Iapetus', kind: 'moon', horizonsCommand: '608', center: '500@699', parentBodyId: 'saturn', poleFollowsOrbit: true },
|
||||||
|
// Phoebe's row gives n = 0.6569114 degrees a day as the table defines it, the rate of its mean
|
||||||
|
// longitude, node plus periapsis plus mean anomaly, and its P, 548.02 days, is 360 over that. Its
|
||||||
|
// sidereal period is 550.30 (its Horizons page and JPL's current table, SAT441): n less twice its
|
||||||
|
// node's rate, 0.6541855, against 360 / 550.30391 = 0.6541840. Triton's row gives its sidereal
|
||||||
|
// rate as n instead, and the propagator reads a retrograde moon's n as that (see
|
||||||
|
// `meanElementsAt`): on the row's n Phoebe ran twice its node's rate too fast, 25 degrees from
|
||||||
|
// Horizons by 2025 and 100 by 2075. On the sidereal period it is within 2.6 from 1950 to 2100
|
||||||
|
// (2.58 in 1969). The table's note on misstated retrograde mean motions is about another source,
|
||||||
|
// Jacobson 2000 on Jupiter's outer moons, and says the table carries the corrected values.
|
||||||
|
{ id: 'phoebe', name: 'Phoebe', kind: 'moon', horizonsCommand: '609', center: '500@699', parentBodyId: 'saturn', spinsFreely: true, periodDays: 550.30391 },
|
||||||
|
{ id: 'miranda', name: 'Miranda', horizonsCommand: '705', ...URANUS_MOON, nodePeriodYears: 17.787 },
|
||||||
|
{ id: 'ariel', name: 'Ariel', horizonsCommand: '701', ...URANUS_MOON },
|
||||||
|
{ id: 'umbriel', name: 'Umbriel', horizonsCommand: '702', ...URANUS_MOON },
|
||||||
|
{ id: 'titania', name: 'Titania', horizonsCommand: '703', ...URANUS_MOON },
|
||||||
|
{ id: 'oberon', name: 'Oberon', horizonsCommand: '704', ...URANUS_MOON },
|
||||||
|
{ id: 'triton', name: 'Triton', kind: 'moon', horizonsCommand: '801', center: '500@899', parentBodyId: 'neptune' },
|
||||||
|
// Nereid's eccentric orbit, 0.75, cannot hold a face to Neptune. Its page states no spin, but
|
||||||
|
// Kepler's K2 light curve gives 11.594 +/- 0.017 hours, confirming the short periods measured
|
||||||
|
// from the ground (Kiss et al. 2016, MNRAS 457, 2908; arXiv:1601.02395). No pole is known.
|
||||||
|
{ id: 'nereid', name: 'Nereid', kind: 'moon', horizonsCommand: '802', center: '500@899', parentBodyId: 'neptune', spinsFreely: true, rotationPeriodHours: 11.594, trackStepDays: 1 },
|
||||||
|
{ id: 'proteus', name: 'Proteus', kind: 'moon', horizonsCommand: '808', center: '500@899', parentBodyId: 'neptune' },
|
||||||
|
// Pluto's section prints its epoch as 2000 Jan 1.0; JPL's current table gives Charon's as
|
||||||
|
// 2000-01-01.5, and read at 1.0 Charon sat 27.8 to 28.2 degrees — half a day of its motion is
|
||||||
|
// 28.2 — from Horizons at every date from 1980 to 2100. At 1.5 it is within 0.4.
|
||||||
|
{ id: 'charon', name: 'Charon', kind: 'moon', horizonsCommand: '901', center: '500@999', parentBodyId: 'pluto', equatorPole: PLUTO_EQUATOR_POLE, epochJd: 2451545.0, barycentric: true }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** The moons whose day is not their orbit; `build.ts` holds every other moon to its lock. */
|
||||||
|
export const FREELY_SPINNING_MOONS = new Set(BODY_SPECS.filter((spec) => spec.spinsFreely).map((spec) => spec.id));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queries JPL Horizons for the osculating orbital elements (and mean radius, where
|
* Writes `bodies.json` for the major planets, the five dwarf planets, and every moon in JPL's
|
||||||
* reported) of the major planets, Pluto, and a curated set of major moons, and writes
|
* mean-element table more than 100 km in mean radius — Phoebe, at 106.6, the smallest: JPL's
|
||||||
* `bodies.json`.
|
* mean orbital elements for where they go, or the SBDB's osculating ones where there are none,
|
||||||
|
* JPL Horizons for their size and spin, and the IAU's rotational elements for where their poles
|
||||||
|
* point and which face is where. Horizons' osculating elements for the same date come back
|
||||||
|
* alongside, for `build.ts` to check the mean ones against.
|
||||||
*/
|
*/
|
||||||
export async function fetchSolarSystem(): Promise<BodyRecord[]> {
|
export async function fetchSolarSystem(): Promise<{ bodies: BodyRecord[]; horizonsOrbits: Map<string, OrbitalElements>; horizonsTracks: Map<string, TrackPoint[]> }> {
|
||||||
console.log(`Fetching ${BODY_SPECS.length} solar-system bodies from JPL Horizons...`);
|
console.log(`Fetching ${BODY_SPECS.length} solar-system bodies from JPL (mean elements, Horizons, NAIF's PCK)...`);
|
||||||
const bodies: BodyRecord[] = [];
|
const bodies: BodyRecord[] = [];
|
||||||
|
const horizonsOrbits = new Map<string, OrbitalElements>();
|
||||||
|
const horizonsTracks = new Map<string, TrackPoint[]>();
|
||||||
|
const planetElements = await fetchPlanetMeanElementsText();
|
||||||
|
const satelliteElements = await fetchSatelliteMeanElementsHtml();
|
||||||
|
const pck = await fetchPckText();
|
||||||
|
// The app turns the Sun by elements it carries itself; they must be the kernel's.
|
||||||
|
if (JSON.stringify(parsePckRotationalElements(pck, 10)?.elements) !== JSON.stringify(SUN_ROTATIONAL_ELEMENTS)) {
|
||||||
|
throw new Error(`The Sun's rotational elements in the app, ${JSON.stringify(SUN_ROTATIONAL_ELEMENTS)}, are not the kernel's.`);
|
||||||
|
}
|
||||||
|
const gmById = new Map<string, number | undefined>();
|
||||||
|
|
||||||
for (const spec of BODY_SPECS) {
|
for (const spec of BODY_SPECS) {
|
||||||
const result = await fetchHorizonsBody({
|
const result = await fetchHorizonsBody({
|
||||||
@@ -52,25 +224,122 @@ export async function fetchSolarSystem(): Promise<BodyRecord[]> {
|
|||||||
cacheKey: `horizons-${spec.id}.txt`
|
cacheKey: `horizons-${spec.id}.txt`
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.radiusKm === undefined) {
|
horizonsOrbits.set(spec.id, result.orbit);
|
||||||
|
gmById.set(spec.id, result.gmKm3PerS2);
|
||||||
|
|
||||||
|
// NAIF numbers a small body 2 000 000 past its catalogue number: Ceres, "1;" to Horizons, is 2000001.
|
||||||
|
const naifId = spec.horizonsCommand.endsWith(';') ? 2_000_000 + Number.parseInt(spec.horizonsCommand, 10) : Number(spec.horizonsCommand);
|
||||||
|
const rotation = parsePckRotationalElements(pck, naifId);
|
||||||
|
if (!rotation) {
|
||||||
|
console.warn(` no IAU rotational elements for ${spec.name}; its pole and meridian are not known.`);
|
||||||
|
} else if (rotation.skippedDeg.length > 0) {
|
||||||
|
console.log(` ${spec.name}: ${rotation.skippedDeg.length} periodic terms under ${MIN_PERIODIC_TERM_DEG} degrees left out, the largest ${Math.max(...rotation.skippedDeg)}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentName = BODY_SPECS.find((candidate) => candidate.id === spec.parentBodyId)?.name;
|
||||||
|
const smallBody = spec.sbdb ? parseSmallBodyElements(await fetchSmallBodyAnswer(spec.sbdb, `sbdb-${spec.id}.json`)) : undefined;
|
||||||
|
const read: MeanOrbit =
|
||||||
|
smallBody ??
|
||||||
|
(parentName
|
||||||
|
? parseSatelliteMeanElements(satelliteElements, parentName, spec.name, spec.apsidesRegress ?? false, spec.equatorPole)
|
||||||
|
: parsePlanetMeanElements(planetElements, spec.id));
|
||||||
|
const nodeRate = spec.nodePeriodYears
|
||||||
|
? Math.sign(read.rates.longitudeOfAscendingNodeDegPerDay) * (360 / (spec.nodePeriodYears * DAYS_PER_JULIAN_YEAR))
|
||||||
|
: read.rates.longitudeOfAscendingNodeDegPerDay;
|
||||||
|
const corrected: MeanOrbit = {
|
||||||
|
...read,
|
||||||
|
orbit: {
|
||||||
|
...read.orbit,
|
||||||
|
longitudeOfAscendingNodeDeg: read.orbit.longitudeOfAscendingNodeDeg + (spec.nodeOffsetDeg ?? 0),
|
||||||
|
epochJd: spec.epochJd ?? read.orbit.epochJd
|
||||||
|
},
|
||||||
|
rates: {
|
||||||
|
...read.rates,
|
||||||
|
...(spec.periodDays ? { meanMotionDegPerDay: 360 / spec.periodDays } : {}),
|
||||||
|
longitudeOfAscendingNodeDegPerDay: nodeRate,
|
||||||
|
// The periapsis's longitude keeps the row's rate; see `nodePeriodYears`.
|
||||||
|
argumentOfPeriapsisDegPerDay: read.rates.argumentOfPeriapsisDegPerDay + (read.rates.longitudeOfAscendingNodeDegPerDay - nodeRate)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (spec.orbitFromW && !rotation) {
|
||||||
|
throw new Error(`${spec.name}'s orbit takes terms from a W the kernel does not give.`);
|
||||||
|
}
|
||||||
|
const fromW = spec.orbitFromW && orbitalTermsOfPrimeMeridian(rotation!.elements, corrected.orbit.epochJd, spec.orbitFromW.angleRateDegPerCentury);
|
||||||
|
const mean: MeanOrbit = fromW
|
||||||
|
? {
|
||||||
|
...corrected,
|
||||||
|
orbit: { ...corrected.orbit, meanAnomalyAtEpochDeg: corrected.orbit.meanAnomalyAtEpochDeg + fromW.meanAnomalyDeg },
|
||||||
|
rates: { ...corrected.rates, meanMotionDegPerDay: corrected.rates.meanMotionDegPerDay + fromW.meanMotionDegPerDay, meanAnomalyTerms: fromW.meanAnomalyTerms }
|
||||||
|
}
|
||||||
|
: corrected;
|
||||||
|
|
||||||
|
// Standish's fit states its own span, 3000 BC to AD 3000. The moons' table and the SBDB state
|
||||||
|
// none, and hold for far less: each card says how far its orbit stays from Horizons over the
|
||||||
|
// span it was measured, where the clock reaches AD 1 to AD 3000. An orbit that took terms from
|
||||||
|
// its IAU W says so first: they move Mimas by up to 44.85 degrees, and are none of JPL's table.
|
||||||
|
let orbitSource = fromW ? `${mean.orbitSource}, with the orbital terms of its IAU W (NAIF pck00011)` : mean.orbitSource;
|
||||||
|
if (parentName || smallBody) {
|
||||||
|
const stepDays = spec.trackStepDays ?? 2;
|
||||||
|
const track = await fetchHorizonsTrack(spec.horizonsCommand, spec.center, stepDays, `horizons-track-${spec.id}-${stepDays}d.txt`);
|
||||||
|
horizonsTracks.set(spec.id, track);
|
||||||
|
const worst = Math.max(...track.map((point) => offsetFromTrackDeg(mean, point)));
|
||||||
|
orbitSource += `, within ${(Math.ceil(worst * 10) / 10).toFixed(1)} degrees of Horizons from ${TRACK_START_YEAR} to ${TRACK_STOP_YEAR}`;
|
||||||
|
}
|
||||||
|
const radiusKm = smallBody?.radiusKm ?? spec.radiusKm ?? result.radiusKm;
|
||||||
|
if (radiusKm === undefined) {
|
||||||
console.warn(` no physical radius found for ${spec.name}; defaulting to 0.`);
|
console.warn(` no physical radius found for ${spec.name}; defaulting to 0.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A moon listed here is tidally locked unless its spec says otherwise, so its day is its
|
||||||
|
// orbit: the sidereal period from the same mean motion that carries it round. Not every page
|
||||||
|
// says so — the Moon's gives a rate, Titan's and Proteus's nothing. Every locked moon here is
|
||||||
|
// turned by its IAU W, at this same rate (see `lockedToOrbit`), and `build.ts` checks that W and
|
||||||
|
// the orbit keep its face to its planet from AD 1 to 3000; this day is what the renderer would
|
||||||
|
// turn a moon without W by.
|
||||||
|
const locked = spec.kind === 'moon' && !spec.spinsFreely;
|
||||||
|
const rotationPeriodHours = result.tidallyLocked || locked
|
||||||
|
? (360 / mean.rates.meanMotionDegPerDay) * HOURS_PER_DAY
|
||||||
|
: (spec.rotationPeriodHours ?? (smallBody ? smallBody.rotationPeriodHours : result.rotationPeriodHours));
|
||||||
|
const parentGm = spec.barycentric && spec.parentBodyId ? gmById.get(spec.parentBodyId) : undefined;
|
||||||
|
if (spec.barycentric && (result.gmKm3PerS2 === undefined || parentGm === undefined)) {
|
||||||
|
throw new Error(`${spec.name} and its planet need a GM each to place their barycentre.`);
|
||||||
|
}
|
||||||
|
if (rotationPeriodHours === undefined) {
|
||||||
|
console.warn(` no rotation period found for ${spec.name}; it will not turn.`);
|
||||||
|
}
|
||||||
|
|
||||||
bodies.push({
|
bodies.push({
|
||||||
id: spec.id,
|
id: spec.id,
|
||||||
systemStarId: SUN_STAR_ID,
|
systemStarId: SUN_STAR_ID,
|
||||||
name: spec.name,
|
name: spec.name,
|
||||||
kind: spec.kind,
|
kind: spec.kind,
|
||||||
radiusKm: result.radiusKm ?? 0,
|
radiusKm: radiusKm ?? 0,
|
||||||
orbit: result.orbit,
|
...(spec.semiAxesKm ? { semiAxesKm: spec.semiAxesKm } : {}),
|
||||||
...(spec.parentBodyId ? { parentBodyId: spec.parentBodyId } : {})
|
orbit: mean.orbit,
|
||||||
|
rates: mean.rates,
|
||||||
|
...(mean.laplacePole ? { laplacePole: mean.laplacePole } : {}),
|
||||||
|
orbitSource,
|
||||||
|
...(spec.measuredEccentricity !== undefined ? { measuredEccentricity: spec.measuredEccentricity } : {}),
|
||||||
|
...(spec.parentBodyId ? { parentBodyId: spec.parentBodyId } : {}),
|
||||||
|
...(parentGm !== undefined ? { massRatio: result.gmKm3PerS2! / parentGm } : {}),
|
||||||
|
...(rotationPeriodHours !== undefined ? { rotationPeriodHours } : {}),
|
||||||
|
...((result.obliquityDeg ?? spec.obliquityDeg) !== undefined ? { obliquityDeg: result.obliquityDeg ?? spec.obliquityDeg } : {}),
|
||||||
|
...(rotation ? { rotationalElements: locked ? lockedToOrbit(rotation.elements, mean, spec.name, spec.poleFollowsOrbit) : rotation.elements } : {})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureDataDir();
|
ensureDataDir();
|
||||||
writeFileSync(dataPath('bodies.json'), JSON.stringify(bodies, null, 2));
|
writeFileSync(dataPath('bodies.json'), JSON.stringify(bodies, null, 2));
|
||||||
console.log(` wrote ${bodies.length} bodies.`);
|
console.log(` wrote ${bodies.length} bodies.`);
|
||||||
return bodies;
|
return { bodies, horizonsOrbits, horizonsTracks };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Degrees between where a moon's or dwarf planet's mean elements put it and where Horizons has it. */
|
||||||
|
export function offsetFromTrackDeg(mean: Pick<MeanOrbit, 'orbit' | 'rates' | 'laplacePole'>, point: TrackPoint): number {
|
||||||
|
const own = positionAtEpoch(meanElementsAt(mean.orbit, mean.rates, point.jd));
|
||||||
|
const place = mean.laplacePole ? laplacePlaneToEquatorial(own, mean.laplacePole) : eclipticToEquatorial(own);
|
||||||
|
const cosine = (place.x * point.x + place.y * point.y + place.z * point.z) / (Math.hypot(place.x, place.y, place.z) * Math.hypot(point.x, point.y, point.z));
|
||||||
|
return (Math.acos(Math.min(1, Math.max(-1, cosine))) * 180) / Math.PI;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { writeFileSync } from 'node:fs';
|
import { writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
import { mergeStarCatalogues, placementDistancePc } from '../../src/app/shared/astro/star-merge';
|
import { foldByIdentity, hipparcosDistancePc, HYG_UNKNOWN_DISTANCE_PC, mergeStarCatalogues, NAKED_EYE_MAGNITUDE, placementDistancePc } from '../../src/app/shared/astro/star-merge';
|
||||||
import { encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
|
import { encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
|
||||||
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
||||||
import { fetchGaiaDistancesByHip, GaiaAnswerError } from './sources/gaia';
|
import { BrightGaiaSource, fetchBrightGaiaSources, fetchGaiaDistancesByHip, fetchHipparcosParallaxErrors, GaiaAnswerError } from './sources/gaia';
|
||||||
import { positionalSources } from './sources/registry';
|
import { positionalSources } from './sources/registry';
|
||||||
|
import { fetchGaiaDesignationsByGj, fetchGaiaDesignationsByHd } from './sources/simbad';
|
||||||
import { PARALLAX_PRECISION_MAS } from './sources/star-sources';
|
import { PARALLAX_PRECISION_MAS } from './sources/star-sources';
|
||||||
import { parseCsvObjects, parseOptionalNumber } from './lib/csv';
|
import { parseCsvObjects, parseOptionalNumber } from './lib/csv';
|
||||||
import { fetchTextCached } from './lib/http';
|
import { fetchTextCached } from './lib/http';
|
||||||
import { dataPath, ensureDataDir } from './lib/paths';
|
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_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
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stand-in magnitude for a star with no photometry. Faint rather than 0, because 0 would mean
|
* Stand-in magnitude for a star with no photometry. Faint rather than 0, because 0 would mean
|
||||||
@@ -21,7 +21,8 @@ const UNKNOWN_MAGNITUDE = 15;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Stars either survey places within this distance (parsecs) of the Sun are kept for the galaxy
|
* Stars either survey places within this distance (parsecs) of the Sun are kept for the galaxy
|
||||||
* view; `placementDistancePc` decides which distance a kept star is drawn at.
|
* view, and every naked-eye star wherever it is; `placementDistancePc` decides which distance a
|
||||||
|
* kept star is drawn at.
|
||||||
*
|
*
|
||||||
* Set at the range Hipparcos's own measurements reach rather than at a round number: its
|
* Set at the range Hipparcos's own measurements reach rather than at a round number: its
|
||||||
* parallaxes are good to roughly a milliarcsecond, so at 250 pc (4 mas) a distance is uncertain
|
* parallaxes are good to roughly a milliarcsecond, so at 250 pc (4 mas) a distance is uncertain
|
||||||
@@ -59,38 +60,73 @@ function resolveName(row: Record<string, string>): string {
|
|||||||
return `HYG ${row['id']}`;
|
return `HYG ${row['id']}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HYG's spectral type, without the `...` that 2 127 of the map's stars end in, all of them
|
||||||
|
* Hipparcos stars: the Hipparcos catalogue's mark for a classification it does not print in full
|
||||||
|
* (Sirius is "A0m..."). On the map it read as text the app had cut short.
|
||||||
|
*/
|
||||||
|
function spectralTypeOf(row: Record<string, string>): string {
|
||||||
|
return (row['spect'] ?? '').replace(/\.\.\.$/, '') || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads the HYG (Hipparcos/Yale/Gliese) stellar database, places each star along its
|
* Downloads the HYG (Hipparcos/Yale/Gliese) stellar database, places each star along its
|
||||||
* equatorial direction (epoch J2000.0) at the better of its Hipparcos and Gaia distances, keeps
|
* equatorial direction (epoch J2000.0) at whichever of its Hipparcos and Gaia distances has the
|
||||||
* the ones either survey puts within range, unions the other positional sources, and writes
|
* smaller error, keeps the ones either survey puts within range, and unions the other positional
|
||||||
* `stars.bin` (packed positions) + `stars-index.json` (everything else).
|
* sources. Writes nothing: fetchExoplanets adds the hosts the catalogue lacks and renames the
|
||||||
|
* ones known only by a designation, then writes the star assets once. Written here as well, they
|
||||||
|
* stood on disk without those 3 277 stars whenever a run stopped between the two — and that
|
||||||
|
* catalogue left 4 237 planets pointing at stars it did not have.
|
||||||
*/
|
*/
|
||||||
export async function fetchStars(): Promise<StarRecord[]> {
|
export async function fetchStars(): Promise<StarRecord[]> {
|
||||||
console.log(`Fetching HYG star catalog (distance cutoff: ${DISTANCE_CUTOFF_PC} pc)...`);
|
console.log(`Fetching HYG star catalog (distance cutoff: ${DISTANCE_CUTOFF_PC} pc)...`);
|
||||||
const csv = await fetchTextCached(HYG_CSV_URL, 'hygdata_v41.csv');
|
const csv = await fetchTextCached(HYG_CSV_URL, 'hygdata_v41.csv');
|
||||||
const rows = parseCsvObjects(csv);
|
const rows = parseCsvObjects(csv);
|
||||||
// Not skipped when unreachable, unlike the positional sources below; see its own comment.
|
// Not skipped when unreachable, unlike the positional sources below; see its own comment.
|
||||||
const gaiaPcByHip = await fetchGaiaDistancesByHip();
|
const gaiaByHip = await fetchGaiaDistancesByHip();
|
||||||
|
const hipparcosErrors = await fetchHipparcosParallaxErrors();
|
||||||
|
const brightGaia = await fetchBrightGaiaSources();
|
||||||
|
const brightByDesignation = new Map(brightGaia.map((source) => [source.designation, source]));
|
||||||
|
const nakedEyeDesignations = await fetchGaiaDesignationsByHd(
|
||||||
|
rows.filter((row) => row['hd'] && Number(row['dist']) >= HYG_UNKNOWN_DISTANCE_PC && (parseOptionalNumber(row['mag']) ?? Infinity) <= NAKED_EYE_MAGNITUDE).map((row) => row['hd'])
|
||||||
|
);
|
||||||
|
|
||||||
const stars: StarRecord[] = [];
|
const stars: StarRecord[] = [];
|
||||||
let atGaiaDistance = 0;
|
let atGaiaDistance = 0;
|
||||||
let pastCutoff = 0;
|
let pastCutoff = 0;
|
||||||
|
let identified = 0;
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const id = Number(row['id']);
|
const id = Number(row['id']);
|
||||||
|
|
||||||
if (id === SUN_STAR_ID) {
|
if (id === SUN_STAR_ID) {
|
||||||
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 });
|
stars.push({ id, name: 'Sol', x: 0, y: 0, z: 0, magnitude: parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE, magnitudeBand: 'V', spectralType: row['spect'] || 'G2V', colorIndex: parseOptionalNumber(row['ci']) ?? null, colorSystem: 'B-V' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hygPc = Number(row['dist']);
|
const hygPc = Number(row['dist']);
|
||||||
const hipparcosPc = Number.isFinite(hygPc) && hygPc > 0 && hygPc < HYG_UNKNOWN_DISTANCE_PC ? hygPc : undefined;
|
const hipparcos = row['hip'] ? hipparcosErrors.get(Number(row['hip'])) : undefined;
|
||||||
const gaiaPc = row['hip'] ? gaiaPcByHip.get(Number(row['hip'])) : undefined;
|
const hipparcosPc = hipparcosDistancePc(hygPc, hipparcos);
|
||||||
const distancePc = placementDistancePc(hipparcosPc, gaiaPc, DISTANCE_CUTOFF_PC);
|
const magnitudeV = parseOptionalNumber(row['mag']);
|
||||||
|
const magnitude = magnitudeV ?? UNKNOWN_MAGNITUDE;
|
||||||
|
const unplaced = hipparcosPc === undefined && magnitude <= NAKED_EYE_MAGNITUDE;
|
||||||
|
const crossMatched = row['hip'] ? gaiaByHip.get(Number(row['hip'])) : undefined;
|
||||||
|
const positional = crossMatched === undefined && unplaced ? brightCounterpart(row, magnitude, brightGaia) : undefined;
|
||||||
|
// Where HYG's position leads to no bright source, the one SIMBAD names it as: HD 45951, whose
|
||||||
|
// declination HYG has 31.7′ out. Within a magnitude of HYG's V, as by position: θ¹ Ori A
|
||||||
|
// (HD 37020) has V 4.98 and O7 in HYG, Hipparcos's entry for it and a companion together, and
|
||||||
|
// its own source G 6.63 (SIMBAD: V 6.73, B0V). Placed, it was drawn brighter than θ¹ Ori C and
|
||||||
|
// counted as naked-eye; at 378 pc and its own magnitude the map does not keep it.
|
||||||
|
const named = crossMatched === undefined && positional === undefined && unplaced ? brightByDesignation.get(nakedEyeDesignations.get(`HD ${row['hd']}`) ?? '') : undefined;
|
||||||
|
const byIdentity = named && Math.abs(named.magnitudeG - magnitude) <= BRIGHT_COUNTERPART_MAGNITUDES ? named : undefined;
|
||||||
|
const gaia = crossMatched ?? positional ?? byIdentity;
|
||||||
|
const gaiaPc = gaia?.distancePc;
|
||||||
|
const hipparcosError = hipparcos?.relativeError;
|
||||||
|
const distancePc = placementDistancePc(hipparcosPc, gaiaPc, magnitude, DISTANCE_CUTOFF_PC, hipparcosError, gaia?.relativeError);
|
||||||
if (distancePc === null) {
|
if (distancePc === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const fromGaia = gaia !== undefined && distancePc === gaiaPc;
|
||||||
|
|
||||||
// HYG's own Cartesian columns rather than its `ra`/`dec`, which are in the same frame as
|
// HYG's own Cartesian columns rather than its `ra`/`dec`, which are in the same frame as
|
||||||
// `raDecDistanceToXyz` and would be redundant if the two agreed. They do not, for the stars
|
// `raDecDistanceToXyz` and would be redundant if the two agreed. They do not, for the stars
|
||||||
@@ -101,42 +137,118 @@ export async function fetchStars(): Promise<StarRecord[]> {
|
|||||||
// HIP 57146, has x/y/z 161″ from its own ra/dec and stays double).
|
// HIP 57146, has x/y/z 161″ from its own ra/dec and stays double).
|
||||||
//
|
//
|
||||||
// Only their direction is used. They sit at HYG's own distance, or at its 100 000 pc
|
// Only their direction is used. They sit at HYG's own distance, or at its 100 000 pc
|
||||||
// placeholder where it has none, and are carried along that direction to the one chosen above.
|
// placeholder where it has none, and are carried along that direction to the one chosen above —
|
||||||
const x = Number(row['x']);
|
// Gaia's, for a star found by its identity, where HYG's may be the thing that is wrong.
|
||||||
const y = Number(row['y']);
|
const [x, y, z] = byIdentity ? [byIdentity.direction.x, byIdentity.direction.y, byIdentity.direction.z] : [Number(row['x']), Number(row['y']), Number(row['z'])];
|
||||||
const z = Number(row['z']);
|
|
||||||
const length = Math.hypot(x, y, z);
|
const length = Math.hypot(x, y, z);
|
||||||
if (![x, y, z].every(Number.isFinite) || length === 0) {
|
if (![x, y, z].every(Number.isFinite) || length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const scale = distancePc / length;
|
const scale = distancePc / length;
|
||||||
if (gaiaPc !== undefined) {
|
if (fromGaia) {
|
||||||
atGaiaDistance++;
|
atGaiaDistance++;
|
||||||
}
|
}
|
||||||
|
if (byIdentity) {
|
||||||
|
identified++;
|
||||||
|
}
|
||||||
if (distancePc > DISTANCE_CUTOFF_PC) {
|
if (distancePc > DISTANCE_CUTOFF_PC) {
|
||||||
pastCutoff++;
|
pastCutoff++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gaia's error with Gaia's distance, Hipparcos's with its own; a Gliese row, with neither, has
|
||||||
|
// no published error, and its distance is as often photometric as measured.
|
||||||
|
const distanceError = fromGaia ? gaia.relativeError : hipparcosError;
|
||||||
|
const colorIndex = parseOptionalNumber(row['ci']);
|
||||||
|
|
||||||
stars.push({
|
stars.push({
|
||||||
id,
|
id,
|
||||||
name: resolveName(row),
|
name: resolveName(row),
|
||||||
x: x * scale,
|
x: x * scale,
|
||||||
y: y * scale,
|
y: y * scale,
|
||||||
z: z * scale,
|
z: z * scale,
|
||||||
magnitude: parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE,
|
magnitude,
|
||||||
spectralType: row['spect'] || 'Unknown',
|
...(magnitudeV === undefined ? {} : { magnitudeBand: 'V' as const }),
|
||||||
colorIndex: parseOptionalNumber(row['ci']) ?? null
|
spectralType: spectralTypeOf(row),
|
||||||
|
colorIndex: colorIndex ?? null,
|
||||||
|
...(colorIndex === undefined ? {} : { colorSystem: 'B-V' as const }),
|
||||||
|
...(distanceError === undefined ? {} : { distanceError }),
|
||||||
|
distanceFromGaia: fromGaia,
|
||||||
|
// Only for the Gliese-only rows, whose positions are what the merge needs the motion to see
|
||||||
|
// past. A Hipparcos position is good to under an arcsecond; given its motion too, 15 stars
|
||||||
|
// took their co-moving companion's Gaia entry, and the companion was kept twice.
|
||||||
|
...(row['hip'] ? {} : { pmRaMasYr: parseOptionalNumber(row['pmra']), pmDecMasYr: parseOptionalNumber(row['pmdec']) })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(` kept ${stars.length} stars (of ${rows.length} in the catalog): ${atGaiaDistance} at Gaia's distance, ${pastCutoff} of them past ${DISTANCE_CUTOFF_PC} pc.`);
|
console.log(
|
||||||
|
` kept ${stars.length} stars (of ${rows.length} in the catalog): ${atGaiaDistance} at Gaia's distance, ${pastCutoff} of them past ${DISTANCE_CUTOFF_PC} pc; ${identified} naked-eye stars placed by the Gaia source SIMBAD names them as.`
|
||||||
|
);
|
||||||
|
|
||||||
const merged = await mergeWithOtherSources(stars);
|
const { stars: merged, folded } = foldByIdentity(await mergeWithOtherSources(stars), await glieseGaiaDesignations(rows));
|
||||||
|
console.log(` ${folded} Gliese entries folded into the Gaia source SIMBAD names them as.`);
|
||||||
merged.sort((a, b) => a.id - b.id);
|
merged.sort((a, b) => a.id - b.id);
|
||||||
writeStarAssets(merged);
|
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How far a naked-eye star may be from its Gaia source, and how much brighter or fainter in G. */
|
||||||
|
const BRIGHT_COUNTERPART_TOLERANCE_RAD = (5 / 3600) * (Math.PI / 180);
|
||||||
|
const BRIGHT_COUNTERPART_MAGNITUDES = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Gaia source that is a naked-eye HYG star neither survey places otherwise: the nearest within
|
||||||
|
* 5″ of its direction and a magnitude of its V. Found this way, HD 152249 is 4.1″ from where HYG
|
||||||
|
* has it, the rest within 1.1″. At 15″, θ¹ Ori (HIP 26220) took the source of θ¹ Ori C, 12.9″ away
|
||||||
|
* and already on the map.
|
||||||
|
*/
|
||||||
|
function brightCounterpart(row: Record<string, string>, magnitudeV: number, sources: readonly BrightGaiaSource[]): { distancePc: number; relativeError: number } | undefined {
|
||||||
|
const [x, y, z] = [Number(row['x']), Number(row['y']), Number(row['z'])];
|
||||||
|
const length = Math.hypot(x, y, z);
|
||||||
|
let best: BrightGaiaSource | undefined;
|
||||||
|
let bestCosine = Math.cos(BRIGHT_COUNTERPART_TOLERANCE_RAD);
|
||||||
|
for (const source of sources) {
|
||||||
|
const cosine = (x * source.direction.x + y * source.direction.y + z * source.direction.z) / length;
|
||||||
|
if (cosine >= bestCosine && Math.abs(source.magnitudeG - magnitudeV) <= BRIGHT_COUNTERPART_MAGNITUDES) {
|
||||||
|
best = source;
|
||||||
|
bestCosine = cosine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HYG's Gliese designation as SIMBAD writes it: "Gl 734B" is "GJ 734 B". */
|
||||||
|
function simbadGliese(gl: string): string {
|
||||||
|
return gl.trim().replace(/^Gl\s+/, 'GJ ').replace(/^(GJ \d+(?:\.\d+)?)\s*([A-Z]+)$/, '$1 $2');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many HYG rows with a Gliese number SIMBAD names a Gaia source for: 3 352 of HYG's 3 801 on
|
||||||
|
* 2026-09-30. SIMBAD's side has its own floor; this one is on the join, where HYG's "Gl 94" has to
|
||||||
|
* become SIMBAD's "GJ 94". Joined on HYG's names as they stand, 38 rows were folded instead of 49
|
||||||
|
* and the check in build.ts, which takes this map too, still counted none left.
|
||||||
|
*/
|
||||||
|
const MIN_GLIESE_IDENTITIES = 3_200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Gaia DR3 designation SIMBAD gives each HYG star with a Gliese number, by HYG id: what the
|
||||||
|
* merge folds its Gliese-only rows by, and what build.ts checks it did. `rows` are HYG's, read
|
||||||
|
* again from the cache when not given.
|
||||||
|
*/
|
||||||
|
export async function glieseGaiaDesignations(rows?: Record<string, string>[]): Promise<Map<number, string>> {
|
||||||
|
const hyg = rows ?? parseCsvObjects(await fetchTextCached(HYG_CSV_URL, 'hygdata_v41.csv'));
|
||||||
|
const byGj = await fetchGaiaDesignationsByGj();
|
||||||
|
const designations = new Map<number, string>();
|
||||||
|
for (const row of hyg) {
|
||||||
|
const designation = row['gl'] ? byGj.get(simbadGliese(row['gl'])) : undefined;
|
||||||
|
if (designation) {
|
||||||
|
designations.set(Number(row['id']), designation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (designations.size < MIN_GLIESE_IDENTITIES) {
|
||||||
|
throw new Error(`Only ${designations.size} HYG stars with a Gliese number were matched to SIMBAD's (at least ${MIN_GLIESE_IDENTITIES} expected) — HYG's designations are no longer written as SIMBAD writes them.`);
|
||||||
|
}
|
||||||
|
return designations;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unions HYG with every other positional source that is wired in and reachable.
|
* Unions HYG with every other positional source that is wired in and reachable.
|
||||||
*
|
*
|
||||||
@@ -183,7 +295,7 @@ async function mergeWithOtherSources(hygStars: StarRecord[]): Promise<StarRecord
|
|||||||
return stars;
|
return stars;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeStarAssets(stars: StarRecord[]): void {
|
export function writeStarAssets(stars: StarRecord[]): void {
|
||||||
ensureDataDir();
|
ensureDataDir();
|
||||||
|
|
||||||
// The layout lives in `star-catalog.ts`, which the app decodes with — one definition, so the
|
// The layout lives in `star-catalog.ts`, which the app decodes with — one definition, so the
|
||||||
@@ -195,8 +307,9 @@ function writeStarAssets(stars: StarRecord[]): void {
|
|||||||
writeFileSync(dataPath('stars-index.json'), JSON.stringify(index));
|
writeFileSync(dataPath('stars-index.json'), JSON.stringify(index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On its own it writes what the ETL would, hosts included; imported late, since fetchExoplanets imports this module.
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
fetchStars().catch((error) => {
|
import('./fetchExoplanets').then(({ fetchExoplanets }) => fetchExoplanets()).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { OrbitalElements } from '../../../src/app/shared/models/body.model';
|
import { OrbitalElements } from '../../../src/app/shared/models/body.model';
|
||||||
|
import { extractGmKm3PerS2, extractObliquityDeg, extractRadiusKm, extractRotationPeriodHours, isTidallyLocked } from '../../../src/app/shared/astro/horizons-page';
|
||||||
import { fetchTextCached } from './http';
|
import { fetchTextCached } from './http';
|
||||||
|
|
||||||
const HORIZONS_URL = 'https://ssd.jpl.nasa.gov/api/horizons.api';
|
const HORIZONS_URL = 'https://ssd.jpl.nasa.gov/api/horizons.api';
|
||||||
@@ -10,7 +11,7 @@ const REFERENCE_START = '2025-01-01';
|
|||||||
const REFERENCE_STOP = '2025-01-02';
|
const REFERENCE_STOP = '2025-01-02';
|
||||||
|
|
||||||
export interface HorizonsQuery {
|
export interface HorizonsQuery {
|
||||||
/** Horizons body id, e.g. `'499'` for Mars. */
|
/** Horizons body id, e.g. `'499'` for Mars, or a small body's number and a semicolon, `'1;'` for Ceres. */
|
||||||
command: string;
|
command: string;
|
||||||
/** Horizons coordinate center, e.g. `'500@10'` (Sun) or `'500@399'` (Earth). */
|
/** Horizons coordinate center, e.g. `'500@10'` (Sun) or `'500@399'` (Earth). */
|
||||||
center: string;
|
center: string;
|
||||||
@@ -20,44 +21,42 @@ export interface HorizonsQuery {
|
|||||||
export interface HorizonsResult {
|
export interface HorizonsResult {
|
||||||
radiusKm?: number;
|
radiusKm?: number;
|
||||||
orbit: OrbitalElements;
|
orbit: OrbitalElements;
|
||||||
|
/**
|
||||||
|
* Sidereal rotation period in hours, negative where the page gives a negative rate —
|
||||||
|
* Venus and Uranus. Absent where the page publishes none. The same pages also give an obliquity
|
||||||
|
* past 90 degrees for those two, which says the same thing again; see the renderer's spinFor.
|
||||||
|
*/
|
||||||
|
rotationPeriodHours?: number;
|
||||||
|
/** Tilt of the rotation axis from the body's own orbital plane, in degrees. */
|
||||||
|
obliquityDeg?: number;
|
||||||
|
/** The page says "Synchronous" instead of a period: its day is its orbit. */
|
||||||
|
tidallyLocked: boolean;
|
||||||
|
/** The body's own GM, km³/s², where the page states one: what sets where a pair's barycentre lies. */
|
||||||
|
gmKm3PerS2?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RADIUS_PATTERNS = [
|
|
||||||
/Vol\.?\s*mean\s*radius[^=]*=\s*([\d.]+)/i,
|
|
||||||
/Mean\s*radius[^=]*=\s*([\d.]+)/i,
|
|
||||||
/Radius\s*\(IAU\)[^=]*=\s*([\d.]+)/i,
|
|
||||||
/Radius,?\s*\(km\)\s*=\s*([\d.]+)/i,
|
|
||||||
/Radius\s*\(gravity\),?\s*km\s*=\s*([\d.]+)/i
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queries JPL Horizons for a body's heliocentric (or planetocentric, for moons) osculating
|
* Queries JPL Horizons for a body's heliocentric (or planetocentric, for moons) osculating
|
||||||
* orbital elements plus, when available, its mean physical radius — both in a single
|
* orbital elements plus, when available, its mean physical radius and how it turns — all in a
|
||||||
* request (`OBJ_DATA=YES` + `EPHEM_TYPE=ELEMENTS`).
|
* single request (`OBJ_DATA=YES` + `EPHEM_TYPE=ELEMENTS`).
|
||||||
*/
|
*/
|
||||||
export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsResult> {
|
export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsResult> {
|
||||||
const url =
|
const url =
|
||||||
`${HORIZONS_URL}?format=text&COMMAND='${query.command}'&OBJ_DATA='YES'` +
|
`${HORIZONS_URL}?format=text&COMMAND='${encodeURIComponent(query.command)}'&OBJ_DATA='YES'` +
|
||||||
`&MAKE_EPHEM='YES'&EPHEM_TYPE='ELEMENTS'&CENTER='${query.center}'` +
|
`&MAKE_EPHEM='YES'&EPHEM_TYPE='ELEMENTS'&CENTER='${query.center}'` +
|
||||||
`&START_TIME='${REFERENCE_START}'&STOP_TIME='${REFERENCE_STOP}'&STEP_SIZE='1d'`;
|
`&START_TIME='${REFERENCE_START}'&STOP_TIME='${REFERENCE_STOP}'&STEP_SIZE='1d'`;
|
||||||
|
|
||||||
const text = await fetchTextCached(url, query.cacheKey);
|
const text = await fetchTextCached(url, query.cacheKey);
|
||||||
return {
|
return {
|
||||||
radiusKm: extractRadiusKm(text),
|
radiusKm: extractRadiusKm(text),
|
||||||
orbit: extractOrbitalElements(text)
|
orbit: extractOrbitalElements(text),
|
||||||
|
rotationPeriodHours: extractRotationPeriodHours(text),
|
||||||
|
obliquityDeg: extractObliquityDeg(text),
|
||||||
|
tidallyLocked: isTidallyLocked(text),
|
||||||
|
gmKm3PerS2: extractGmKm3PerS2(text)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractRadiusKm(text: string): number | undefined {
|
|
||||||
for (const pattern of RADIUS_PATTERNS) {
|
|
||||||
const match = text.match(pattern);
|
|
||||||
if (match) {
|
|
||||||
return Number(match[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractOrbitalElements(text: string): OrbitalElements {
|
function extractOrbitalElements(text: string): OrbitalElements {
|
||||||
const startIndex = text.indexOf('$$SOE');
|
const startIndex = text.indexOf('$$SOE');
|
||||||
const endIndex = text.indexOf('$$EOE');
|
const endIndex = text.indexOf('$$EOE');
|
||||||
@@ -94,3 +93,40 @@ function extractNumber(text: string, pattern: RegExp): number {
|
|||||||
}
|
}
|
||||||
return Number(match[1]);
|
return Number(match[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The span a moon's or dwarf planet's mean elements are checked against Horizons over, and the card names. */
|
||||||
|
export const TRACK_START_YEAR = 1950;
|
||||||
|
export const TRACK_STOP_YEAR = 2100;
|
||||||
|
|
||||||
|
/** One Horizons position: TDB Julian date, and ICRF equatorial coordinates in AU from the centre. */
|
||||||
|
export interface TrackPoint {
|
||||||
|
jd: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where Horizons has a body, from its centre, every `stepDays` from 1950 to 2100: the ephemeris the
|
||||||
|
* mean elements are checked against over the whole span, where one date saw a moon at its best.
|
||||||
|
*/
|
||||||
|
export async function fetchHorizonsTrack(command: string, center: string, stepDays: number, cacheKey: string): Promise<TrackPoint[]> {
|
||||||
|
const url =
|
||||||
|
`${HORIZONS_URL}?format=text&COMMAND='${encodeURIComponent(command)}'&OBJ_DATA='NO'&MAKE_EPHEM='YES'` +
|
||||||
|
`&EPHEM_TYPE='VECTORS'&CENTER='${center}'&START_TIME='${TRACK_START_YEAR}-01-01'&STOP_TIME='${TRACK_STOP_YEAR}-01-01'` +
|
||||||
|
`&STEP_SIZE='${stepDays}%20d'&REF_PLANE='FRAME'&REF_SYSTEM='ICRF'&VEC_TABLE='1'&OUT_UNITS='AU-D'&CSV_FORMAT='YES'&VEC_CORR='NONE'`;
|
||||||
|
const text = await fetchTextCached(url, cacheKey);
|
||||||
|
const startIndex = text.indexOf('$$SOE');
|
||||||
|
const endIndex = text.indexOf('$$EOE');
|
||||||
|
if (startIndex === -1 || endIndex === -1) {
|
||||||
|
throw new Error(`Horizons gave no vectors for ${command} from ${center}: ${text.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
.slice(startIndex + '$$SOE'.length, endIndex)
|
||||||
|
.trim()
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((row) => {
|
||||||
|
const [jd, , x, y, z] = row.split(',').map((field) => field.trim());
|
||||||
|
return { jd: Number(jd), x: Number(x), y: Number(y), z: Number(z) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { eclipticToEquatorial, laplacePlaneToEquatorial } from '../../../src/app/shared/astro/coordinates';
|
||||||
|
import { meanElementsAt, positionAtEpoch } from '../../../src/app/shared/astro/kepler';
|
||||||
|
import { MeanOrbit } from '../../../src/app/shared/astro/mean-elements';
|
||||||
|
import { orientationAt } from '../../../src/app/shared/astro/rotational-elements';
|
||||||
|
import { BodyRecord, RotationalElements } from '../../../src/app/shared/models/body.model';
|
||||||
|
|
||||||
|
const J2000_JD = 2451545;
|
||||||
|
const DAYS_PER_JULIAN_CENTURY = 36525;
|
||||||
|
const DEG_TO_RAD = Math.PI / 180;
|
||||||
|
|
||||||
|
/** 2025-01-01, the date the ETL asks Horizons about: where a locked moon's W is left as the IAU has it. */
|
||||||
|
export const PRESENT_JD = 2460676.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the IAU's W rate for a locked moon may be from the mean motion its orbit is drawn at, as
|
||||||
|
* a fraction of it, before it is taken for the orbit's. Measured: at most 3.4e-6 (Iapetus; Proteus
|
||||||
|
* 6.3e-7). What this catches is a rate read for the wrong body: Oberon's for Titania's is 55 per cent out.
|
||||||
|
*/
|
||||||
|
const MAX_LOCKED_RATE_OFFSET = 1e-5;
|
||||||
|
|
||||||
|
/** Harmonics of the node's angle that carry a pole round its orbit's; see {@link lockedToOrbit}. */
|
||||||
|
const POLE_HARMONICS = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a periodic term's angle may turn from a multiple of the node's rate, as a fraction of it,
|
||||||
|
* and still be taken for the node's angle as the IAU's source had it. Measured: at most 3.1e-2
|
||||||
|
* (Callisto's J6), then Rhea's R4 1.2e-2 and Ganymede's J5 3.3e-3; the nearest that is not a node is
|
||||||
|
* a term of Miranda's W alone, 6.0e-2 from three times it. Multiples go up to the ninth, the most the
|
||||||
|
* report takes (Triton's N7); past that, Umbriel's W has a term 1.0e-2 from ten times its node's.
|
||||||
|
*/
|
||||||
|
const MAX_NODE_RATE_OFFSET = 0.05;
|
||||||
|
const MAX_NODE_HARMONIC = 9;
|
||||||
|
|
||||||
|
/** How far, in degrees, re-rating may move a locked moon's pole or W at {@link PRESENT_JD}; see {@link lockedToOrbit}. */
|
||||||
|
const MAX_PRESENT_OFFSET_DEG = 1e-6;
|
||||||
|
|
||||||
|
/** The planet's east longitude on a moon's IAU body-fixed frame, from the moon's mean place, at a TDB date. */
|
||||||
|
export function subPlanetLongitudeDeg(body: Pick<BodyRecord, 'orbit' | 'rates' | 'laplacePole'>, elements: RotationalElements, jd: number): number {
|
||||||
|
const own = positionAtEpoch(meanElementsAt(body.orbit, body.rates, jd));
|
||||||
|
const place = body.laplacePole ? laplacePlaneToEquatorial(own, body.laplacePole) : eclipticToEquatorial(own);
|
||||||
|
const { poleRaDeg, poleDecDeg, primeMeridianDeg } = orientationAt(elements, jd);
|
||||||
|
const pole = { raDeg: poleRaDeg, decDeg: poleDecDeg };
|
||||||
|
const w = primeMeridianDeg * DEG_TO_RAD;
|
||||||
|
const meridian = laplacePlaneToEquatorial({ x: Math.cos(w), y: Math.sin(w), z: 0 }, pole);
|
||||||
|
const east = laplacePlaneToEquatorial({ x: -Math.sin(w), y: Math.cos(w), z: 0 }, pole);
|
||||||
|
const along = (axis: { x: number; y: number; z: number }) => -(place.x * axis.x + place.y * axis.y + place.z * axis.z);
|
||||||
|
return Math.atan2(along(east), along(meridian)) / DEG_TO_RAD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A locked moon's IAU elements, turned at the rate its orbit is drawn at, so it keeps its face to
|
||||||
|
* its planet over the clock's AD 1 to 3000 and not only near the present its W was fitted to.
|
||||||
|
*
|
||||||
|
* The report gives a locked moon's W the mean motion of whichever orbit its authors had, and JPL's
|
||||||
|
* table has another: Proteus's W turns 6.3e-7 of its rate slower than its row, which turned its far
|
||||||
|
* side to Neptune at AD 1 (146 degrees), Mimas's 1.6e-7 faster (52 at AD 1) and Miranda's (23).
|
||||||
|
* W's rate is set to the orbit's here, its constant moved so W is unchanged at {@link PRESENT_JD}.
|
||||||
|
* Measured over AD 1-3000: Proteus 2.7 degrees, Mimas 8.9, Miranda 2.4, Ariel 1.0. A W with a
|
||||||
|
* quadratic is left: Phobos's orbit already takes the quadratic from W (see
|
||||||
|
* `orbitalTermsOfPrimeMeridian`), and the Moon's, its tidal slowing, is 0.75 degrees at AD 1.
|
||||||
|
*
|
||||||
|
* The node's angle goes the same way. A moon in a Cassini state keeps its axis on its orbit normal,
|
||||||
|
* which goes round the Laplace pole with the node, and the IAU's pole goes round with it on a term
|
||||||
|
* of the node's angle, at the node's rate as its source had it, not quite JPL's current one the
|
||||||
|
* orbit is drawn at (see `nodePeriodYears` in `fetchSolarSystem.ts`): Rhea's R4 turns 1.2 per cent
|
||||||
|
* faster than its node, Callisto's J6 3.1 per cent, and Miranda's U11 0.013 per cent, which on a
|
||||||
|
* 4.4-degree circle over twenty centuries still adds up. On the IAU's rates the axes part from the
|
||||||
|
* drawn orbits by AD 1 or 3000: Rhea's by 0.77 degrees, Miranda's 0.59, Triton's 0.51, Europa's and
|
||||||
|
* Callisto's 0.33. Every term whose angle turns within {@link MAX_NODE_RATE_OFFSET} of a multiple of
|
||||||
|
* the node's rate is set to that multiple, its constant moved so the angle is unchanged at the
|
||||||
|
* present, and the pole with it: over AD 1-3000 the axes of Io, Europa, Ganymede, Callisto, Rhea,
|
||||||
|
* Miranda and Triton stay within 0.23 degrees of their orbit normals, and Mimas's, whose drawn node
|
||||||
|
* takes the IAU's S3 itself, within 0.44. The rest of the pole and its terms are the IAU's, and so
|
||||||
|
* are all of the Moon's and Phobos's, left whole with their W: Ariel's, Umbriel's, Titania's and
|
||||||
|
* Oberon's poles go round on angles of their own at none of their nodes' multiples (Oberon's, the
|
||||||
|
* nearest, 8.7 per cent from three times its node's rate), and their axes stay within 0.50 degrees
|
||||||
|
* of their orbit normals without.
|
||||||
|
*
|
||||||
|
* `poleFollowsOrbit` is for Iapetus, whose IAU pole moves 3.9 degrees a century in right ascension
|
||||||
|
* and 1.1 in declination: a straight line through its orbit normal's 3 439-year circle round the
|
||||||
|
* Laplace pole, 8.3 degrees in radius (16.6 across), which by AD 1 has run past the celestial pole (Dec 97.9) and 11
|
||||||
|
* degrees off the orbit, and turned its face 87 degrees from Saturn. Its axis sits on its orbit normal
|
||||||
|
* (0.04 degrees apart today), as a moon in a Cassini state keeps it, so its pole is given the circle: the
|
||||||
|
* normal's right ascension as sines and declination as cosines of the node's angle and its first
|
||||||
|
* {@link POLE_HARMONICS} harmonics, the IAU's own form for a precessing pole, with its constants
|
||||||
|
* set so the pole is the IAU's at the present. W counts from where the equator crosses the ICRF
|
||||||
|
* equator, which swings as the pole goes round, so W takes sines of the same angles, fitted to hold
|
||||||
|
* the face where it is today. Measured over AD 1-3000: the axis within 0.74 degrees of the orbit
|
||||||
|
* normal (the IAU's line, 11.06), and the face within 16 of Saturn (87), which is what the row's own
|
||||||
|
* 9.4-degree lag and its eccentricity make it from 1950 to 2100 as well (15.9).
|
||||||
|
*/
|
||||||
|
export function lockedToOrbit(elements: RotationalElements, mean: Pick<MeanOrbit, 'orbit' | 'rates' | 'laplacePole'>, name: string, poleFollowsOrbit = false): RotationalElements {
|
||||||
|
const [w0, w1, w2 = 0] = elements.primeMeridianDeg;
|
||||||
|
if (w2 !== 0) {
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
const n = Math.sign(w1) * mean.rates.meanMotionDegPerDay;
|
||||||
|
if (Math.abs(w1 / n - 1) > MAX_LOCKED_RATE_OFFSET) {
|
||||||
|
throw new Error(`${name}'s IAU W turns at ${w1} degrees a day, ${Math.abs(w1 / n - 1).toExponential(2)} of its orbit's ${n}: not the rate of the orbit it keeps its face to.`);
|
||||||
|
}
|
||||||
|
const nodeRate = mean.rates.longitudeOfAscendingNodeDegPerDay * DAYS_PER_JULIAN_CENTURY;
|
||||||
|
const present = (PRESENT_JD - J2000_JD) / DAYS_PER_JULIAN_CENTURY;
|
||||||
|
const terms = elements.terms?.map((term) => {
|
||||||
|
const [constant, rate, quadratic = 0] = term.angleDeg;
|
||||||
|
const k = Math.round(rate / nodeRate);
|
||||||
|
if (quadratic !== 0 || k === 0 || Math.abs(k) > MAX_NODE_HARMONIC || Math.abs(rate / (k * nodeRate) - 1) > MAX_NODE_RATE_OFFSET) {
|
||||||
|
return term;
|
||||||
|
}
|
||||||
|
return { ...term, angleDeg: [constant + (rate - k * nodeRate) * present, k * nodeRate] };
|
||||||
|
});
|
||||||
|
const locked: RotationalElements = { ...elements, primeMeridianDeg: [w0 + (w1 - n) * (PRESENT_JD - J2000_JD), n, 0], ...(terms ? { terms } : {}) };
|
||||||
|
const turned = poleFollowsOrbit ? poleRoundOrbit(locked, mean) : locked;
|
||||||
|
// Whatever is re-rated, the pole and W at the present are the kernel's, which is what they were
|
||||||
|
// fitted to. Measured: at most 4.7e-10 degrees (Deimos's W, some 2.6 million degrees round).
|
||||||
|
const [iau, own] = [elements, turned].map((each) => orientationAt(each, PRESENT_JD));
|
||||||
|
const moved = Math.max(...(['poleRaDeg', 'poleDecDeg', 'primeMeridianDeg'] as const).map((key) => Math.abs(own[key] - iau[key])));
|
||||||
|
if (moved > MAX_PRESENT_OFFSET_DEG) {
|
||||||
|
throw new Error(`${name}'s pole or W on ${PRESENT_JD} is ${moved.toExponential(2)} degrees from the IAU's: re-rated, it should be where the kernel has it today.`);
|
||||||
|
}
|
||||||
|
return turned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function poleRoundOrbit(elements: RotationalElements, mean: Pick<MeanOrbit, 'orbit' | 'rates' | 'laplacePole'>): RotationalElements {
|
||||||
|
if (!mean.laplacePole) {
|
||||||
|
throw new Error('A pole that follows its orbit is carried round the orbit\'s Laplace pole, and this orbit has none.');
|
||||||
|
}
|
||||||
|
const laplacePole = mean.laplacePole;
|
||||||
|
const normalAt = (jd: number) => {
|
||||||
|
const { inclinationDeg, longitudeOfAscendingNodeDeg } = meanElementsAt(mean.orbit, mean.rates, jd);
|
||||||
|
const tilt = inclinationDeg * DEG_TO_RAD;
|
||||||
|
const node = longitudeOfAscendingNodeDeg * DEG_TO_RAD;
|
||||||
|
const normal = laplacePlaneToEquatorial({ x: Math.sin(tilt) * Math.sin(node), y: -Math.sin(tilt) * Math.cos(node), z: Math.cos(tilt) }, laplacePole);
|
||||||
|
return { raDeg: Math.atan2(normal.y, normal.x) / DEG_TO_RAD, decDeg: Math.asin(normal.z) / DEG_TO_RAD };
|
||||||
|
};
|
||||||
|
// The node's angle, T in centuries, turned so that 0 is where the normal is furthest north: the
|
||||||
|
// circle is then even in declination and odd in right ascension about it, as the form requires.
|
||||||
|
const nodeRate = mean.rates.longitudeOfAscendingNodeDegPerDay * DAYS_PER_JULIAN_CENTURY;
|
||||||
|
const nodeAtJ2000 = meanElementsAt(mean.orbit, mean.rates, J2000_JD).longitudeOfAscendingNodeDeg;
|
||||||
|
const jdAtAngle = (angleDeg: number, phaseDeg: number) => J2000_JD + ((angleDeg - phaseDeg - nodeAtJ2000) / nodeRate) * DAYS_PER_JULIAN_CENTURY;
|
||||||
|
let phase = 0;
|
||||||
|
let northmost = -Infinity;
|
||||||
|
for (let candidate = 0; candidate < 360; candidate += 0.01) {
|
||||||
|
const dec = normalAt(jdAtAngle(0, candidate)).decDeg;
|
||||||
|
if (dec > northmost) {
|
||||||
|
northmost = dec;
|
||||||
|
phase = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const centre = laplacePole;
|
||||||
|
const samples = 3600;
|
||||||
|
const ra = new Array<number>(POLE_HARMONICS + 1).fill(0);
|
||||||
|
const dec = new Array<number>(POLE_HARMONICS + 1).fill(0);
|
||||||
|
for (let sample = 0; sample < samples; sample++) {
|
||||||
|
const angle = (sample / samples) * 360;
|
||||||
|
const normal = normalAt(jdAtAngle(angle, phase));
|
||||||
|
const raOffset = ((((normal.raDeg - centre.raDeg) % 360) + 540) % 360) - 180;
|
||||||
|
for (let k = 0; k <= POLE_HARMONICS; k++) {
|
||||||
|
ra[k] += (2 / samples) * raOffset * Math.sin(k * angle * DEG_TO_RAD);
|
||||||
|
dec[k] += ((k === 0 ? 1 : 2) / samples) * (normal.decDeg - centre.decDeg) * Math.cos(k * angle * DEG_TO_RAD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const terms = Array.from({ length: POLE_HARMONICS }, (_, index) => {
|
||||||
|
const k = index + 1;
|
||||||
|
return { angleDeg: [k * (nodeAtJ2000 + phase), k * nodeRate], ra: ra[k], dec: dec[k], pm: 0 };
|
||||||
|
});
|
||||||
|
const round: RotationalElements = { ...elements, poleRaDeg: [centre.raDeg, 0, 0], poleDecDeg: [centre.decDeg + dec[0], 0, 0], terms: [...(elements.terms ?? []), ...terms] };
|
||||||
|
// The IAU's pole at the present, exactly: the fitted circle's constants moved onto it.
|
||||||
|
const iau = orientationAt(elements, PRESENT_JD);
|
||||||
|
const fitted = orientationAt(round, PRESENT_JD);
|
||||||
|
round.poleRaDeg = [round.poleRaDeg[0] + iau.poleRaDeg - fitted.poleRaDeg, 0, 0];
|
||||||
|
round.poleDecDeg = [round.poleDecDeg[0] + iau.poleDecDeg - fitted.poleDecDeg, 0, 0];
|
||||||
|
// W's sines on the same angles, fitted over a turn of the node to what the face drifts by.
|
||||||
|
const pm = new Array<number>(POLE_HARMONICS + 1).fill(0);
|
||||||
|
const wSamples = 36000;
|
||||||
|
for (let sample = 0; sample < wSamples; sample++) {
|
||||||
|
const angle = (sample / wSamples) * 360;
|
||||||
|
const drift = subPlanetLongitudeDeg(mean, round, jdAtAngle(angle, phase));
|
||||||
|
for (let k = 1; k <= POLE_HARMONICS; k++) {
|
||||||
|
pm[k] += (2 / wSamples) * drift * Math.sin(k * angle * DEG_TO_RAD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ownTerms = elements.terms?.length ?? 0;
|
||||||
|
const turned: RotationalElements = { ...round, terms: round.terms!.map((term, index) => (index < ownTerms ? term : { ...term, pm: pm[index - ownTerms + 1] })) };
|
||||||
|
// And W the IAU's at the present.
|
||||||
|
const shift = orientationAt(turned, PRESENT_JD).primeMeridianDeg - orientationAt(elements, PRESENT_JD).primeMeridianDeg;
|
||||||
|
turned.primeMeridianDeg = [turned.primeMeridianDeg[0] - shift, turned.primeMeridianDeg[1], 0];
|
||||||
|
return turned;
|
||||||
|
}
|
||||||