Compare commits
251
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
321540ed91 | ||
|
|
cea4ff799f | ||
|
|
0f659af5b3 | ||
|
|
74913d844d | ||
|
|
98082a0460 | ||
|
|
5620776601 | ||
|
|
e7c420b296 | ||
|
|
c8d1bbb879 | ||
|
|
ef2099177a | ||
|
|
0a88797ff4 | ||
|
|
6a45c914ac | ||
|
|
c0ed91dcf9 | ||
|
|
ee168996f7 | ||
|
|
4951fd8657 | ||
|
|
d5a5a0fc3a | ||
|
|
69cada7914 | ||
|
|
24c7be1bae | ||
|
|
4c1e19d635 | ||
|
|
280159dcd2 | ||
|
|
f31ffe1425 | ||
|
|
ac23496b37 | ||
|
|
67a21b4a10 | ||
|
|
4d47896b4a | ||
|
|
7080a5a817 | ||
|
|
a9e910c2ea | ||
|
|
2f49002027 | ||
|
|
030447b83c | ||
|
|
74ea1d655e | ||
|
|
8d59c720b3 | ||
|
|
5fb0d45623 | ||
|
|
734b048c9e | ||
|
|
a15a46c617 | ||
|
|
073fb9f718 | ||
|
|
dadae7b8ca | ||
|
|
e62e2fb03f | ||
|
|
4e7d4cd1d6 | ||
|
|
2dac3767e8 | ||
|
|
fc7677e715 | ||
|
|
e8e857ec25 | ||
|
|
3b4fd1af6c | ||
|
|
f8582b78d0 | ||
|
|
368fcfb6f0 | ||
|
|
17cf11b6b1 | ||
|
|
09eaf9532f | ||
|
|
74535accc5 | ||
|
|
aafc788352 | ||
|
|
206e88ae85 | ||
|
|
28fa79e1e0 | ||
|
|
3e1bd33b6c | ||
|
|
c2683da37b | ||
|
|
89c2568018 | ||
|
|
a70a7290f3 | ||
|
|
69a052730f | ||
|
|
15b8f2dff3 | ||
|
|
5ab4c5df89 | ||
|
|
8f99335bae | ||
|
|
95ffb009a2 | ||
|
|
a8f394cf57 | ||
|
|
06b4ff64b5 | ||
|
|
18faa6d0b2 | ||
|
|
d449214309 | ||
|
|
936d1c01f8 | ||
|
|
f6bb2b9584 | ||
|
|
83dc46416b | ||
|
|
acc4b928f2 | ||
|
|
4cd08ee324 | ||
|
|
48fd6fd0e5 | ||
|
|
ff744a00de | ||
|
|
8c3a86097c | ||
|
|
23547defe0 | ||
|
|
c45d9160e3 | ||
|
|
f82b5e1c74 | ||
|
|
6d81c45f45 | ||
|
|
3453cd5d9f | ||
|
|
85cb66e63c | ||
|
|
20c34f9662 | ||
|
|
62f81f2c43 | ||
|
|
cbe4908219 | ||
|
|
1a8e734651 | ||
|
|
0e9ab6ffb5 | ||
|
|
150ec76bbd | ||
|
|
4ebe663a84 | ||
|
|
08bf8b18a6 | ||
|
|
f8af0ecf6e | ||
|
|
4191b70e63 | ||
|
|
fa52f6a818 | ||
|
|
d1aa22ed61 | ||
|
|
5aac46de08 | ||
|
|
31e0c04fe1 | ||
|
|
dc7277f740 | ||
|
|
d93bb1ee2c | ||
|
|
98c4eb901f | ||
|
|
a281f22f34 | ||
|
|
d097f4b477 | ||
|
|
2d4b8a98d5 | ||
|
|
d94451e203 | ||
|
|
6c0626ca38 | ||
|
|
869635bec1 | ||
|
|
8c4f1c11c9 | ||
|
|
036af5f02d | ||
|
|
56af5e3553 | ||
|
|
34084c0eec | ||
|
|
ff7525d3b5 | ||
|
|
97b8dd9dc8 | ||
|
|
8e3a494fe9 | ||
|
|
f655a4c0f7 | ||
|
|
86d97b903e | ||
|
|
395b613f82 | ||
|
|
87e9ec274b | ||
|
|
34ae803c06 | ||
|
|
d4808788ec | ||
|
|
f8ee3ac3ab | ||
|
|
2242fe0a7f | ||
|
|
7213f987c4 | ||
|
|
db3af1a820 | ||
|
|
ac6a3bb1ea | ||
|
|
3392f06c85 | ||
|
|
81a4ecfcde | ||
|
|
b46c840369 | ||
|
|
1e3ac57229 | ||
|
|
70cbdae306 | ||
|
|
dc20accfdf | ||
|
|
302fa963ad | ||
|
|
5333be615e | ||
|
|
a81dd491ad | ||
|
|
76fb38665f | ||
|
|
cdf474bcd5 | ||
|
|
1c86584642 | ||
|
|
ab7d454db1 | ||
|
|
4c8e4a02f3 | ||
|
|
494fb5616b | ||
|
|
1d42be2ad5 | ||
|
|
de34ffff43 | ||
|
|
7b65ab4812 | ||
|
|
b0d7989c6f | ||
|
|
c01d3ec2bc | ||
|
|
cdc0cf4678 | ||
|
|
74b93a0428 | ||
|
|
2e5daa0f97 | ||
|
|
48319c3fe2 | ||
|
|
c64eea0803 | ||
|
|
c38a42cbcb | ||
|
|
468c98b14a | ||
|
|
4b276e44a5 | ||
|
|
225d676ab0 | ||
|
|
3f0abf8717 | ||
|
|
e45c3b6287 | ||
|
|
2d06408c3f | ||
|
|
56870fd9fe | ||
|
|
cea39c7186 | ||
|
|
03b3d3b2d6 | ||
|
|
0c5efec505 | ||
|
|
337602f606 | ||
|
|
1a5785fb63 | ||
|
|
dd56eafba4 | ||
|
|
7fba48c808 | ||
|
|
1a53f26474 | ||
|
|
b7f277ea04 | ||
|
|
7ab92e61a1 | ||
|
|
365252f534 | ||
|
|
5dec528cee | ||
|
|
7971ec4007 | ||
|
|
862fb65ea4 | ||
|
|
44f6a8d086 | ||
|
|
b4017fcafc | ||
|
|
539a0f0a3e | ||
|
|
bd5e9d4b0d | ||
|
|
45d5209433 | ||
|
|
977a4d8a92 | ||
|
|
3026cbde5f | ||
|
|
c65a02e9bc | ||
|
|
9631ddf0a4 | ||
|
|
9ad0815acd | ||
|
|
a769d70015 | ||
|
|
52c5d3b144 | ||
|
|
6cd0666067 | ||
|
|
7064e34d02 | ||
|
|
c6206a8311 | ||
|
|
c77d3ed1d9 | ||
|
|
96348161cc | ||
|
|
962cb3f6bc | ||
|
|
e986f6bebf | ||
|
|
7efcd2e93a | ||
|
|
e1c806d05b | ||
|
|
58656a5e34 | ||
|
|
f7a482b06a | ||
|
|
011ebe1356 | ||
|
|
fd5a24ce72 | ||
|
|
f156e03822 | ||
|
|
965739e99e | ||
|
|
86e143131e | ||
|
|
c3fcb2e481 | ||
|
|
b071d87d8a | ||
|
|
7f8fb59f5d | ||
|
|
8c69a7a8b2 | ||
|
|
2d997e41db | ||
|
|
0a0b301807 | ||
|
|
7f187e000e | ||
|
|
efe6667b00 | ||
|
|
43b9b1f081 | ||
|
|
4eb61ff58e | ||
|
|
29d3ddb6ef | ||
|
|
f935bae3b3 | ||
|
|
db511f7aa4 | ||
|
|
037545d036 | ||
|
|
3df5396349 | ||
|
|
080bbe16dc | ||
|
|
9cef316359 | ||
|
|
dc2ce08694 | ||
|
|
08534279fb | ||
|
|
043d57f0e7 | ||
|
|
7a112e4bb3 | ||
|
|
f14e252b19 | ||
|
|
14f3fe8e66 | ||
|
|
82d70ce871 | ||
|
|
42f304b30b | ||
|
|
4e2575674d | ||
|
|
3612d722c5 | ||
|
|
6c5cc1ca19 | ||
|
|
911d876f55 | ||
|
|
0c190539fe | ||
|
|
c11087cb8f | ||
|
|
ee02147a78 | ||
|
|
46cb923849 | ||
|
|
59c2d8483e | ||
|
|
e808f50faa | ||
|
|
7c16ad8627 | ||
|
|
971aa66303 | ||
|
|
8ceea02875 | ||
|
|
005ba07750 | ||
|
|
b05324337c | ||
|
|
687bc3b01c | ||
|
|
d397aaa7e0 | ||
|
|
18e9d85a1b | ||
|
|
b2cb307b60 | ||
|
|
50c0351870 | ||
|
|
a381c02cd0 | ||
|
|
f42337c841 | ||
|
|
e853fe312e | ||
|
|
bd37bb8b96 | ||
|
|
d9bd913458 | ||
|
|
307fd41be8 | ||
|
|
9cdd8f9388 | ||
|
|
bd3a5a48c9 | ||
|
|
68a919bd84 | ||
|
|
c1e7f6363d | ||
|
|
a6b30e84f0 | ||
|
|
44c6a1f15e | ||
|
|
7591bcc0ea | ||
|
|
8b507427d8 | ||
|
|
4a1cc5240a |
@@ -110,12 +110,17 @@ node .claude/skills/run-star-map/driver.mjs probe inner
|
||||
{ "name": "Earth", "kind": "Planet" },
|
||||
{ "name": "Mercury", "kind": "Planet" },
|
||||
{ "name": "Venus", "kind": "Planet" }
|
||||
],
|
||||
"neighbours": [
|
||||
{ "name": "Proxima Centauri", "distance": "1.30 pc" },
|
||||
{ "name": "Barnard's Star", "distance": "1.82 pc" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`probe` accepts the same four view names. Labels are read from the CSS2D layer
|
||||
(`.map-label` > `.map-label-name` + `.map-label-kind`).
|
||||
(`.map-label` > `.map-label-name` + `.map-label-kind`). `neighbours` is the ring of nearby
|
||||
stars named from inside a system (`.map-label--ghost`); it is absent where there are none.
|
||||
|
||||
## Run (human path)
|
||||
|
||||
|
||||
@@ -211,21 +211,27 @@ async function probe(page, view) {
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Labels are plain DOM in a CSS2D layer: .map-label > .map-label-name + .map-label-kind.
|
||||
const labels = await page.locator('.map-label').evaluateAll((nodes) =>
|
||||
// Neighbouring stars named from inside a system carry .map-label--ghost; they are reported
|
||||
// apart from the system's own bodies, since they are not in the system being probed.
|
||||
const all = await page.locator('.map-label').evaluateAll((nodes) =>
|
||||
nodes
|
||||
.filter((n) => n.offsetParent !== null)
|
||||
.map((n) => ({
|
||||
name: n.querySelector('.map-label-name')?.textContent?.trim() ?? '',
|
||||
kind: n.querySelector('.map-label-kind')?.textContent?.trim() ?? null
|
||||
kind: n.querySelector('.map-label-kind')?.textContent?.trim() ?? null,
|
||||
ghost: n.classList.contains('map-label--ghost')
|
||||
}))
|
||||
);
|
||||
const labels = all.filter((label) => !label.ghost).map(({ name, kind }) => ({ name, kind }));
|
||||
const neighbours = all.filter((label) => label.ghost).map(({ name, kind }) => ({ name, distance: kind }));
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
title: (await title(page).textContent())?.trim(),
|
||||
level: (await level(page).textContent())?.trim(),
|
||||
labelCount: labels.length,
|
||||
labels
|
||||
labels,
|
||||
...(neighbours.length ? { neighbours } : {})
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -37,14 +37,19 @@ jobs:
|
||||
# different dependency tree than the one committed.
|
||||
- run: npm ci
|
||||
|
||||
# Four TypeScript projects, checked by four different things. These two have no build of
|
||||
# their own, so nothing else would ever compile them.
|
||||
# Five TypeScript projects, checked by four different things. These three have no build of
|
||||
# their own that checks them, so nothing else would ever compile them. The worker is bundled by
|
||||
# the build, but esbuild only strips its types, and `tsconfig.app.json` leaves it out, since
|
||||
# its lib is `webworker` rather than `dom`.
|
||||
- name: Typecheck the ETL
|
||||
run: npm run etl:typecheck
|
||||
|
||||
- name: Typecheck the end-to-end tests
|
||||
run: npm run e2e:typecheck
|
||||
|
||||
- name: Typecheck the routing worker
|
||||
run: npm run worker:typecheck
|
||||
|
||||
# `tsconfig.spec.json` is compiled here, `tsconfig.app.json` by the build below.
|
||||
- name: Unit tests
|
||||
run: npm test -- --no-watch
|
||||
|
||||
@@ -39,9 +39,26 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
|
||||
# The ETL's cache directory is gitignored and this is a fresh runner, so every source is
|
||||
# fetched live (~50-100 MB). A failed fetch fails the run by design — no refresh is
|
||||
# better than a partial one — except Gaia, which the ETL itself treats as best-effort.
|
||||
# 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
|
||||
# rather than re-downloaded every week from an archive that times out under load. So is
|
||||
# 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
|
||||
with:
|
||||
path: |
|
||||
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
|
||||
# design — no refresh is better than a partial one. That includes Gaia on a cold cache, by
|
||||
# two different paths: its Hipparcos cross-match is required, so an unreachable archive
|
||||
# fails the run from fetchStars itself, while its main query is skipped when unreachable and
|
||||
# the merge gate in build.ts then refuses a catalogue it contributed nothing to. An archive
|
||||
# that answers short rather than not at all is caught in fetchGaiaStars.
|
||||
- name: Rebuild the datasets
|
||||
run: npm run etl
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ name: Junie review
|
||||
# it reads well, and a review comment should never be able to turn the build red.
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review]
|
||||
# `reopened` because a pull request closed and reopened has had no review since it was
|
||||
# closed, and `ready_for_review` because the draft guard below would otherwise skip a pull
|
||||
# request opened as a draft forever. No `branches:` filter: work here stacks feature onto
|
||||
# feature, so filtering on main would skip every pull request in a chain but the last.
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
# A review of the previous push is stale the moment a new one lands, so supersede it rather than
|
||||
# letting two reviews comment on the same pull request. Keyed by pull request rather than by ref
|
||||
@@ -25,6 +29,11 @@ jobs:
|
||||
review:
|
||||
name: Review the diff
|
||||
runs-on: ubuntu-latest
|
||||
# A ceiling, not a target: a run that goes wrong hangs rather than stops, and the pull
|
||||
# request shows a pending check until it does. Set above the longest review this repository
|
||||
# has actually had — 35 minutes, on the largest diff so far — rather than at the sibling
|
||||
# repositories' 30, which would have cut that one short.
|
||||
timeout-minutes: 45
|
||||
# Drafts are work in progress and forks cannot see `JUNIE_API_KEY` — GitHub withholds secrets
|
||||
# from `pull_request` runs on forked branches, so the job would fail on a missing key rather
|
||||
# than say anything useful about the code.
|
||||
@@ -54,7 +63,13 @@ jobs:
|
||||
# clone is never used.
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: JetBrains/junie-github-action@v1
|
||||
# Pinned to a commit rather than to `v1`: this is the only third-party action here and it
|
||||
# is handed a repository secret, so its definition should not be able to change under us.
|
||||
# `v1` resolves to this same commit today; the pin is about who gets to move it. The pin
|
||||
# covers this definition only — the composite pulls its own dependencies by tag and fetches
|
||||
# the Junie CLI over the network. Bump by resolving the new release's commit, never by
|
||||
# moving a tag name.
|
||||
- uses: JetBrains/junie-github-action@3f6a906f11c6f67c76efaf3d3264bbb615f9ce29 # v1.7.5
|
||||
if: env.HAS_JUNIE_KEY == 'true'
|
||||
# An opinion, not a gate. If Junie is down or rate-limited that is worth seeing in the
|
||||
# log, but it is not a reason to hold a pull request whose tests pass.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
drop line from each body, so eccentricity and inclination read against a circular reference
|
||||
instead of having to be inferred from a shape in space. The camera frames that grid rather than
|
||||
the orbits, from the field of view it actually has, so the outermost ring sits inside the frame
|
||||
with room around it at any system scale and any window shape.
|
||||
|
||||
The star at the centre is sized against the system's *innermost* orbit, so it can never swallow
|
||||
its closest planet, while the camera is placed to frame the *outermost* ring — and in the solar
|
||||
system those differ by a factor of a hundred. At the distance that fits Pluto in view, a disc
|
||||
that stays clear of Mercury is about a pixel across, and no radius satisfies both. So the disc
|
||||
stays honest to the orbits and the star's halo carries its visibility, floored against the framed
|
||||
radius: light is not a surface, and a glow reaching past the innermost orbit says the star is
|
||||
bright rather than that it is large. That floor is bounded from both sides — large enough that
|
||||
the star reads at a glance, small enough that Venus's and Earth's orbits stay legible as rings
|
||||
around it. Mercury's, three pixels wide at that range, does not survive either way.
|
||||
The star at the centre is drawn at its own radius, to the same scale as its orbits: the
|
||||
archive's measured radius for a planet host, and otherwise one derived from its luminosity and
|
||||
temperature (Stefan-Boltzmann), which the card marks with what the temperature came from: "from
|
||||
colour and brightness", "from its type and brightness" for a giant or a star whose colour the
|
||||
dwarf table does not read, or "from its temperature and brightness" for an archive host whose
|
||||
colour was read off its temperature. Its surface is a limb-darkened disc in the colour of a
|
||||
blackbody at its temperature, and its planets are lit
|
||||
in that colour, relative to the Sun's, so the solar system's photographs stay as they were
|
||||
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
|
||||
point sources, so apparent size should follow brightness, not distance.
|
||||
- **One reference frame, from three sources.** HYG gives star positions in equatorial J2000.
|
||||
JPL Horizons reports orbital elements against the ecliptic, tilted 23.4° away. The Exoplanet
|
||||
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
|
||||
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
|
||||
@@ -134,9 +149,11 @@ its own readout, so a stale image is visible as one.
|
||||
|
||||
### On surfaces that were never photographed
|
||||
|
||||
Fifteen bodies here have a real photograph. Everything else does not, and never will on current
|
||||
instruments: no exoplanet's surface has ever been imaged, and a few of the solar system's own
|
||||
moons have no usable map in this asset set either.
|
||||
Twenty-eight bodies here are wrapped in real photography: the Sun, the eight planets and the Moon,
|
||||
and eighteen moons and dwarf planets in mission mosaics, grey where no probe has seen them
|
||||
(`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
|
||||
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 |
|
||||
| --- | --- | --- |
|
||||
| `fetchStars.ts` | HYG database, plus any other positional catalogue wired in (see below) | `stars.bin`, `stars-meta.bin`, `stars-index.json` |
|
||||
| `fetchSolarSystem.ts` | JPL Horizons / SSD | `bodies.json` |
|
||||
| `fetchExoplanets.ts` | NASA Exoplanet Archive (TAP) | `exoplanets.json` |
|
||||
| `fetchStars.ts` | HYG database, plus any other positional catalogue wired in (see below) | the catalogue stars, handed to `fetchExoplanets.ts` |
|
||||
| `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), and the stars above with the hosts it adds | `exoplanets.json`, `stars.bin`, `stars-meta.bin`, `stars-index.json` |
|
||||
| `fetchDeepSky.ts` | OpenNGC | `deepsky.json` |
|
||||
|
||||
The star catalogue ships as two binary column stores plus a small JSON file, not as an array of
|
||||
objects. At 68 388 stars the old encoding — one JSON object per star, its eight key names
|
||||
repeated each time — would have been about 17 MB to download and parse before the first frame.
|
||||
Splitting it puts the numbers in `stars.bin` (positions, handed to the GPU verbatim) and
|
||||
`stars-meta.bin` (id, magnitude, colour index, spectral-type index), and leaves `stars-index.json`
|
||||
`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
|
||||
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.
|
||||
@@ -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
|
||||
backdrop shell where true distance would be unusable anyway. `distancePc` is optional metadata,
|
||||
derived from parallax for galactic objects or the Hubble law for genuinely distant galaxies,
|
||||
and left `null` — with its `distanceMethod` — whenever neither is trustworthy. Roughly 330 of
|
||||
the 463 cataloged objects get a distance; the rest honestly report none.
|
||||
and left `null` — with its `distanceMethod` — whenever neither is trustworthy. 340 of
|
||||
the 488 cataloged objects get a distance; the rest honestly report none.
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
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,62 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/** Every request the article service makes, whichever language it is asking in. */
|
||||
const isWikipedia = (url: URL): boolean => url.hostname.endsWith('wikipedia.org');
|
||||
|
||||
/** The article Wikipedia would send, stubbed: this suite tests the panel, not the encyclopedia. */
|
||||
const SUMMARY = {
|
||||
type: 'standard',
|
||||
titles: { normalized: 'Titan (moon)' },
|
||||
extract: 'Titan is the largest moon of Saturn.',
|
||||
content_urls: { desktop: { page: 'https://en.wikipedia.org/wiki/Titan_(moon)' } }
|
||||
};
|
||||
|
||||
test.describe('About', () => {
|
||||
test('fetches an article only when asked, and says where it came from', async ({ page }) => {
|
||||
let requests = 0;
|
||||
await page.route(isWikipedia, async (route) => {
|
||||
requests++;
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SUMMARY) });
|
||||
});
|
||||
|
||||
await page.goto('/body/titan');
|
||||
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Nothing has been fetched yet: the panel is measurements until a reader asks for prose.
|
||||
expect(requests).toBe(0);
|
||||
await expect(page.getByText('Titan is the largest moon')).toHaveCount(0);
|
||||
|
||||
await page.getByRole('button', { name: 'About', exact: true }).click();
|
||||
|
||||
await expect(page.getByText('Titan is the largest moon of Saturn.')).toBeVisible();
|
||||
const credit = page.getByRole('link', { name: /Wikipedia/ });
|
||||
await expect(credit).toHaveAttribute('href', 'https://en.wikipedia.org/wiki/Titan_(moon)');
|
||||
expect(requests).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('says nothing is written rather than leaving the press unanswered', async ({ page }) => {
|
||||
await page.route(isWikipedia, (route) => route.fulfill({ status: 404, body: '{}' }));
|
||||
|
||||
await page.goto('/body/titan');
|
||||
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: 'About', exact: true }).click();
|
||||
|
||||
await expect(page.getByText(/Wikipedia has no article on Titan/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('tells being unable to ask apart from there being no answer, and offers to try again', async ({ page }) => {
|
||||
await page.route(isWikipedia, (route) => route.abort('failed'));
|
||||
|
||||
await page.goto('/body/titan');
|
||||
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: 'About', exact: true }).click();
|
||||
await expect(page.getByText(/could not be reached/)).toBeVisible();
|
||||
|
||||
// And the retry actually retries, rather than reading back a remembered failure.
|
||||
await page.unroute(isWikipedia);
|
||||
await page.route(isWikipedia, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SUMMARY) }));
|
||||
await page.getByRole('button', { name: 'Try again' }).click();
|
||||
|
||||
await expect(page.getByText('Titan is the largest moon of Saturn.')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { openSearch } from './support/open-search';
|
||||
|
||||
test.describe('Bookmarks', () => {
|
||||
test('keeps a body, comes back to it in a later visit, and forgets it', async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
await page.goto('/body/earth');
|
||||
await expect(page.getByRole('heading', { name: 'Earth' })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByRole('button', { name: 'Keep Earth' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Forget Earth' })).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
// A later visit, on a different page: kept places outlive the one they were kept from.
|
||||
await page.goto('/');
|
||||
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
|
||||
// it waits on is the slowest thing in it. The default five seconds is a coin toss
|
||||
// under a full parallel run.
|
||||
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: 'Bookmarks' }).click();
|
||||
|
||||
const kept = page.locator('#dock-panel-bookmarks li').filter({ hasText: 'Earth' });
|
||||
await expect(kept).toHaveCount(1);
|
||||
await kept.getByRole('button').first().click();
|
||||
|
||||
// Choosing it goes there, which for a body is its page.
|
||||
await expect(page).toHaveURL(/\/body\/earth$/, { timeout: 30_000 });
|
||||
await expect(page.getByRole('heading', { name: 'Earth' })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByRole('button', { name: 'Forget Earth' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Keep Earth' })).toBeVisible();
|
||||
|
||||
await page.getByRole('tab', { name: 'Bookmarks' }).click();
|
||||
await expect(page.locator('#dock-panel-bookmarks')).toContainText('Nothing kept yet');
|
||||
});
|
||||
|
||||
test('keeps the system the view is inside, and flies back to it', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto('/?stars=4000');
|
||||
|
||||
const searchInput = await openSearch(page);
|
||||
await searchInput.fill('Proxima Centauri');
|
||||
await page.getByRole('button', { name: /Proxima Centauri/ }).first().click();
|
||||
|
||||
const readout = page.getByTestId('hud-title');
|
||||
await expect(readout).toHaveText('Proxima Centauri', { timeout: 30_000 });
|
||||
await page.getByRole('button', { name: 'Keep Proxima Centauri' }).click();
|
||||
|
||||
// Back out to the field, then return by what was kept rather than by searching again.
|
||||
await page.getByRole('button', { name: 'Solar Neighbourhood' }).click();
|
||||
await expect(readout).toHaveText('Local Stars', { timeout: 30_000 });
|
||||
|
||||
await page.getByRole('tab', { name: 'Bookmarks' }).click();
|
||||
await page.locator('#dock-panel-bookmarks li').filter({ hasText: 'Proxima Centauri' }).getByRole('button').first().click();
|
||||
|
||||
await expect(readout).toHaveText('Proxima Centauri', { timeout: 45_000 });
|
||||
});
|
||||
});
|
||||
+26
-3
@@ -9,8 +9,12 @@ test.describe('Galaxy view', () => {
|
||||
test('boots the app, initializes the 3D scene, and starts in the galaxy overview (no system controls shown)', async ({ page }) => {
|
||||
await page.goto('/?stars=4000');
|
||||
|
||||
await expect(page.getByTestId('scene-canvas')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('Search stars, planets, exoplanets…')).toBeVisible();
|
||||
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
|
||||
// it waits on is the slowest thing in it. The default five seconds is a coin toss
|
||||
// under a full parallel run.
|
||||
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||
// The dock's tab strip is up before the scene finishes booting; the search is one tab in it.
|
||||
await expect(page.getByRole('tab', { name: 'Search' })).toBeVisible();
|
||||
await expect(backButtonLocator(page)).toHaveCount(0);
|
||||
// The readout panel's own title, not just the text anywhere on screen: the selected-object
|
||||
// banner across the top names the same thing, so a bare text match is ambiguous.
|
||||
@@ -22,7 +26,7 @@ test.describe('Galaxy view', () => {
|
||||
// up to more than the default per-test budget.
|
||||
test.setTimeout(90_000);
|
||||
await page.goto('/?stars=4000');
|
||||
await expect(page.getByTestId('scene-canvas')).toBeVisible();
|
||||
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByRole('button', { name: 'Milky Way' }).click();
|
||||
|
||||
@@ -36,4 +40,23 @@ test.describe('Galaxy view', () => {
|
||||
await page.getByRole('button', { name: 'Solar Neighbourhood' }).click();
|
||||
await expect(page.getByTestId('hud-title')).toHaveText('Local Stars', { timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('a scale bar and labelled rings say how far things are, and follow the zoom', async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto('/?stars=4000');
|
||||
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The rings are distances from the Sun, the survey's own edge called out among them.
|
||||
await expect(page.getByText('Survey edge')).toBeVisible({ timeout: 30_000 });
|
||||
const scale = page.getByTestId('hud-scale');
|
||||
await expect(scale).toHaveAttribute('aria-label', /^Scale: [\d.]+ k?pc$/);
|
||||
const opening = await scale.getAttribute('aria-label');
|
||||
|
||||
// Zooming in shortens the round length the bar stands for.
|
||||
await page.getByTestId('scene-canvas').hover();
|
||||
for (let notch = 0; notch < 10; notch++) {
|
||||
await page.mouse.wheel(0, -400);
|
||||
}
|
||||
await expect(scale).not.toHaveAttribute('aria-label', opening ?? '', { timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { openSearch } from './support/open-search';
|
||||
|
||||
test.describe('Neighbour jump', () => {
|
||||
test('a neighbour named from inside one system flies into that one', async ({ page }) => {
|
||||
// Two full camera flights on a software rasterizer shared with the rest of the suite: into
|
||||
// Sol, then out and into the star its label names. See the same note on camera-flight.
|
||||
test.setTimeout(120_000);
|
||||
await page.goto('/?stars=4000');
|
||||
|
||||
const searchInput = await openSearch(page);
|
||||
await searchInput.fill('Sol');
|
||||
await page.getByRole('button', { name: /^Sol\b/ }).first().click();
|
||||
|
||||
const readout = page.getByTestId('hud-title');
|
||||
await expect(readout).toHaveText('Sol', { timeout: 30_000 });
|
||||
|
||||
// Its nearest neighbours are named around the edge of the view; each is a button that flies
|
||||
// there. Barnard's Star by name, on purpose: the ring ranks named stars ahead of survey
|
||||
// designations, and the four nearest named to the Sun — Proxima, Rigil Kentaurus, Toliman,
|
||||
// Barnard's — are a fact about space, not about which catalogue was refreshed last. Before
|
||||
// that preference, a Gaia row duplicating Proxima took fourth place and this very label was
|
||||
// the one that vanished, so it is also the regression test for it. Barnard's rather than the
|
||||
// Alpha Centauri trio because those three share one bearing and are decluttered down to
|
||||
// whichever the label pass reaches first.
|
||||
const neighbour = page.getByRole('button', { name: /Barnard's Star/ });
|
||||
await expect(neighbour).toBeVisible({ timeout: 30_000 });
|
||||
await neighbour.click();
|
||||
|
||||
await expect(readout).toHaveText("Barnard's Star", { timeout: 45_000 });
|
||||
// And from there the walk goes on: the new system names its own neighbours. Which ones is
|
||||
// not asserted — several of Barnard's nearest share a bearing, so which of them survives
|
||||
// the declutter is a property of the view, not a fact about the catalogue.
|
||||
await expect(page.locator('.map-label--ghost')).not.toHaveCount(0, { timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { openSearch } from './support/open-search';
|
||||
|
||||
test.describe('Plan view', () => {
|
||||
test('flattens a system onto its own orbital plane, and unflattens it', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto('/?stars=4000');
|
||||
|
||||
const searchInput = await openSearch(page);
|
||||
await searchInput.fill('Sol');
|
||||
await page.getByRole('button', { name: /^Sol\b/ }).first().click();
|
||||
await expect(page.getByTestId('hud-title')).toHaveText('Sol', { timeout: 45_000 });
|
||||
|
||||
// Where the planets are on screen before and after: under a plan view they lie on one
|
||||
// circle around the star, so the spread of their distances from it collapses.
|
||||
const spread = async () =>
|
||||
page.evaluate(() => {
|
||||
const labels = [...document.querySelectorAll('.map-label:not(.map-label--ghost)')];
|
||||
const centre = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
||||
const radii = labels.map((label) => {
|
||||
const box = label.getBoundingClientRect();
|
||||
return Math.hypot(box.left - centre.x, box.top + box.height / 2 - centre.y);
|
||||
});
|
||||
return radii.length;
|
||||
});
|
||||
|
||||
await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2);
|
||||
|
||||
await page.getByRole('tab', { name: 'Display' }).click();
|
||||
const plan = page.getByRole('button', { name: 'Plan view', exact: true });
|
||||
await expect(plan).toHaveAttribute('aria-pressed', 'false');
|
||||
await plan.click();
|
||||
await expect(plan).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
// The scene survives the swap: it is still this system, still labelled, still readable.
|
||||
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
|
||||
// it waits on is the slowest thing in it. The default five seconds is a coin toss
|
||||
// under a full parallel run.
|
||||
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||
await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2);
|
||||
await page.getByRole('tab', { name: 'Readout' }).click();
|
||||
await expect(page.getByTestId('hud-title')).toHaveText('Sol');
|
||||
|
||||
await page.getByRole('tab', { name: 'Display' }).click();
|
||||
await plan.click();
|
||||
await expect(plan).toHaveAttribute('aria-pressed', 'false');
|
||||
await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
test('keeps the scale ladder honest about how far out the view is', async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto('/?stars=4000');
|
||||
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('hud-current-level')).toHaveText('Solar Neighbourhood', { timeout: 30_000 });
|
||||
|
||||
await page.getByRole('tab', { name: 'Display' }).click();
|
||||
await page.getByRole('button', { name: 'Plan view', exact: true }).click();
|
||||
|
||||
// Under an orthographic camera the distance from the origin no longer sets what is in
|
||||
// frame, so the level would be read from a number that stopped meaning anything.
|
||||
await expect(page.getByTestId('hud-current-level')).toHaveText('Solar Neighbourhood');
|
||||
await page.getByRole('button', { name: 'Milky Way' }).click();
|
||||
await expect(page.getByTestId('hud-current-level')).toHaveText('Milky Way', { timeout: 45_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Route plotting', () => {
|
||||
test('chains one star to another through the crossings a chosen range allows', async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto('/?stars=4000');
|
||||
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
|
||||
// it waits on is the slowest thing in it. The default five seconds is a coin toss
|
||||
// under a full parallel run.
|
||||
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByRole('tab', { name: 'Routes' }).click();
|
||||
|
||||
// Sirius is 2.64 pc from the Sun, so the default 3 pc range crosses it in one.
|
||||
await page.locator('#route-from').fill('Sol');
|
||||
const departure = page.locator('#dock-panel-routes ul li button').first();
|
||||
await expect(departure).toBeVisible({ timeout: 30_000 });
|
||||
await departure.click();
|
||||
|
||||
await page.locator('#route-to').fill('Sirius');
|
||||
const destination = page.locator('#dock-panel-routes ul li button').first();
|
||||
await expect(destination).toBeVisible();
|
||||
await destination.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Plot route' }).click();
|
||||
|
||||
await expect(page.getByTestId('route-summary')).toHaveText(/1 jump · 2\.6\d pc/);
|
||||
// The chain itself, departure first, each step a way to fly there.
|
||||
const steps = page.getByTestId('route-steps').getByRole('button');
|
||||
await expect(steps).toHaveCount(2);
|
||||
await expect(steps.first()).toContainText('Sol');
|
||||
await expect(steps.last()).toContainText('Sirius');
|
||||
});
|
||||
|
||||
test('says what range a crossing would need, rather than only that there is none', async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto('/?stars=4000');
|
||||
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByRole('tab', { name: 'Routes' }).click();
|
||||
await page.locator('#route-from').fill('Sol');
|
||||
const departure = page.locator('#dock-panel-routes ul li button').first();
|
||||
await expect(departure).toBeVisible({ timeout: 30_000 });
|
||||
await departure.click();
|
||||
|
||||
// Narrowed until nothing the catalogue holds is within reach of the Sun: its nearest
|
||||
// neighbour is 1.30 pc away, so half a parsec strands it.
|
||||
await page.locator('#route-range').fill('0.5');
|
||||
await page.locator('#route-to').fill('Sirius');
|
||||
const destination = page.locator('#dock-panel-routes ul li button').first();
|
||||
await expect(destination).toBeVisible();
|
||||
await destination.click();
|
||||
await page.getByRole('button', { name: 'Plot route' }).click();
|
||||
|
||||
const summary = page.getByTestId('route-summary');
|
||||
await expect(summary).toContainText('No route at this range', { timeout: 30_000 });
|
||||
// And the answer, not just the refusal: the range that would open one, offered as a control.
|
||||
const raise = summary.getByRole('button');
|
||||
await expect(raise).toContainText(/pc would reach/);
|
||||
await raise.click();
|
||||
|
||||
await expect(page.getByTestId('route-summary')).toContainText(/jump/, { timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,28 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { openSearch } from './support/open-search';
|
||||
import { backButtonLocator } from './support/wait-for-back-button';
|
||||
|
||||
test.describe('Search-driven navigation', () => {
|
||||
test('selecting a star result flies into that system and shows the back-to-galaxy control', async ({ page }) => {
|
||||
await page.goto('/?stars=4000');
|
||||
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
|
||||
const searchInput = await openSearch(page);
|
||||
await searchInput.fill('Proxima Centauri');
|
||||
|
||||
const result = page.getByRole('button', { name: /Proxima Centauri/ });
|
||||
await expect(result).toBeVisible();
|
||||
await result.click();
|
||||
|
||||
// Selecting a result clears the search query immediately (before the flight even starts).
|
||||
await expect(searchInput).toHaveValue('');
|
||||
// Selecting a result hands the dock straight back to the readout (before the flight even
|
||||
// starts): the thing to look at is now the scene, and the search panel folds away with it.
|
||||
await expect(page.getByRole('tab', { name: 'Readout' })).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(searchInput).toHaveCount(0);
|
||||
await expect(backButtonLocator(page)).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('selecting a body result navigates straight to its detail route and shows real NASA data', async ({ page }) => {
|
||||
await page.goto('/?stars=4000');
|
||||
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
|
||||
const searchInput = await openSearch(page);
|
||||
await searchInput.fill('Earth');
|
||||
|
||||
const result = page.getByText('Earth', { exact: true });
|
||||
@@ -36,7 +39,7 @@ test.describe('Search-driven navigation', () => {
|
||||
|
||||
test('typing fewer than two characters shows no results, and Escape clears the query', async ({ page }) => {
|
||||
await page.goto('/?stars=4000');
|
||||
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
|
||||
const searchInput = await openSearch(page);
|
||||
|
||||
await searchInput.fill('E');
|
||||
await expect(page.getByTestId('search-results')).toHaveCount(0);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The search lives in the dock along the bottom, behind its own tab, so a test that wants to
|
||||
* type has to open it first — the way a user does, or with the `/` shortcut. Returns the field.
|
||||
*/
|
||||
export async function openSearch(page: Page): Promise<Locator> {
|
||||
// Clicking the active tab folds it closed, so only click when it is not already open.
|
||||
const tab = page.getByRole('tab', { name: 'Search' });
|
||||
if ((await tab.getAttribute('aria-selected')) !== 'true') {
|
||||
await tab.click();
|
||||
}
|
||||
return page.getByPlaceholder('Search stars, planets, exoplanets…');
|
||||
}
|
||||
+2
-1
@@ -10,7 +10,8 @@
|
||||
"etl": "tsx tools/etl/build.ts",
|
||||
"etl:typecheck": "tsc -p tools/etl/tsconfig.json --noEmit",
|
||||
"e2e": "playwright test",
|
||||
"e2e:typecheck": "tsc -p e2e/tsconfig.json --noEmit"
|
||||
"e2e:typecheck": "tsc -p e2e/tsconfig.json --noEmit",
|
||||
"worker:typecheck": "tsc -p tsconfig.worker.json --noEmit"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@11.12.1",
|
||||
|
||||
@@ -11,6 +11,12 @@ export default defineConfig({
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: 'html',
|
||||
// Well above Playwright's 5 s, which was a fair ceiling when the star catalogue was 820 kB and
|
||||
// is not now that the scheduled refresh has it at 5.4 MB and 447 410 rows: every one of these
|
||||
// tests boots that catalogue, and the suite boots several at once on a software rasterizer.
|
||||
// The heavy waits already carry their own longer timeouts; this is the same judgement applied
|
||||
// to the assertions that were left on the default. A ceiling costs nothing when it is not hit.
|
||||
expect: { timeout: 15_000 },
|
||||
use: {
|
||||
baseURL: 'http://localhost:4300',
|
||||
trace: 'on-first-retry'
|
||||
|
||||
@@ -4,5 +4,4 @@
|
||||
aria-hidden="true"
|
||||
class="hud-brackets pointer-events-none fixed inset-2 z-30 [--hud-tick:1.75rem]"
|
||||
></div>
|
||||
<app-search></app-search>
|
||||
<router-outlet></router-outlet>
|
||||
|
||||
+1
-3
@@ -1,11 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
import { SearchComponent } from './features/search/search.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, SearchComponent],
|
||||
imports: [RouterOutlet],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
})
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ArticleService } from './article.service';
|
||||
|
||||
function summary(overrides: Record<string, unknown> = {}): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: 'standard',
|
||||
titles: { normalized: 'Titan (moon)' },
|
||||
extract: 'Titan is the largest moon of Saturn.',
|
||||
content_urls: { desktop: { page: 'https://en.wikipedia.org/wiki/Titan_(moon)' } },
|
||||
...overrides
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
function missing(): Response {
|
||||
return new Response('{}', { status: 404 });
|
||||
}
|
||||
|
||||
/** What Wikipedia sends anyone who asks too quickly. */
|
||||
function rateLimited(): Response {
|
||||
return new Response('You are making too many requests to the API.', { status: 429 });
|
||||
}
|
||||
|
||||
function service(): ArticleService {
|
||||
TestBed.resetTestingModule();
|
||||
return TestBed.inject(ArticleService);
|
||||
}
|
||||
|
||||
/** The language each call was made in, in order, so the fallback chain can be asserted. */
|
||||
function languagesAsked(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
||||
return fetchMock.mock.calls.map((call) => new URL(String(call[0])).hostname.split('.')[0]);
|
||||
}
|
||||
|
||||
function titlesAsked(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
||||
return fetchMock.mock.calls.map((call) => decodeURIComponent(String(call[0]).split('/summary/')[1]));
|
||||
}
|
||||
|
||||
describe('ArticleService', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal('navigator', { language: 'en-GB' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('hands back what Wikipedia wrote, and where to go and check it', async () => {
|
||||
fetchMock.mockResolvedValue(summary());
|
||||
|
||||
const result = await service().lookup('Titan');
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'found',
|
||||
article: {
|
||||
title: 'Titan (moon)',
|
||||
extract: 'Titan is the largest moon of Saturn.',
|
||||
url: 'https://en.wikipedia.org/wiki/Titan_(moon)',
|
||||
language: 'en'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('asks in the browser’s language first and English second', async () => {
|
||||
vi.stubGlobal('navigator', { language: 'fr-FR' });
|
||||
fetchMock.mockResolvedValueOnce(missing()).mockResolvedValueOnce(summary());
|
||||
|
||||
const result = await service().lookup('Titan');
|
||||
|
||||
expect(languagesAsked(fetchMock)).toEqual(['fr', 'en']);
|
||||
expect(result.status).toBe('found');
|
||||
});
|
||||
|
||||
it('asks English only once, when English is what the browser is set to', async () => {
|
||||
fetchMock.mockResolvedValue(summary());
|
||||
|
||||
await service().lookup('Titan');
|
||||
|
||||
expect(languagesAsked(fetchMock)).toEqual(['en']);
|
||||
});
|
||||
|
||||
it('names the kind when the plain name lands on a list of other things', async () => {
|
||||
fetchMock.mockResolvedValueOnce(summary({ type: 'disambiguation' })).mockResolvedValueOnce(summary());
|
||||
|
||||
const result = await service().lookup('Titan', 'moon');
|
||||
|
||||
expect(titlesAsked(fetchMock)).toEqual(['Titan', 'Titan (moon)']);
|
||||
expect(result.status).toBe('found');
|
||||
});
|
||||
|
||||
it('says there is nothing written rather than pretending, when nothing is', async () => {
|
||||
fetchMock.mockResolvedValue(missing());
|
||||
|
||||
expect(await service().lookup('HD 224700', 'planet')).toEqual({ status: 'none' });
|
||||
});
|
||||
|
||||
it('tells being unable to ask apart from there being no answer', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('offline'));
|
||||
|
||||
expect(await service().lookup('Titan')).toEqual({ status: 'unavailable' });
|
||||
});
|
||||
|
||||
it('counts the rate limit as being unable to ask, not as an empty answer', async () => {
|
||||
fetchMock.mockResolvedValue(rateLimited());
|
||||
|
||||
expect(await service().lookup('Titan')).toEqual({ status: 'unavailable' });
|
||||
});
|
||||
|
||||
it('asks once per body, however many times it is asked for', async () => {
|
||||
fetchMock.mockResolvedValue(summary());
|
||||
const articles = service();
|
||||
|
||||
await articles.lookup('Titan');
|
||||
await articles.lookup('Titan');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('remembers that there is nothing, but never that it could not ask', async () => {
|
||||
const articles = service();
|
||||
|
||||
fetchMock.mockResolvedValue(missing());
|
||||
await articles.lookup('Nowhere');
|
||||
await articles.lookup('Nowhere');
|
||||
const afterMissing = fetchMock.mock.calls.length;
|
||||
|
||||
fetchMock.mockRejectedValue(new TypeError('offline'));
|
||||
await articles.lookup('Elsewhere');
|
||||
const afterFirstFailure = fetchMock.mock.calls.length;
|
||||
await articles.lookup('Elsewhere');
|
||||
|
||||
// One round of requests for the missing page, then nothing more; but a failure to reach
|
||||
// Wikipedia is a fact about this minute, so pressing again is allowed to try again.
|
||||
expect(afterMissing).toBe(1);
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThan(afterFirstFailure);
|
||||
});
|
||||
|
||||
it('treats an article with nothing in it as no article', async () => {
|
||||
fetchMock.mockResolvedValue(summary({ extract: ' ' }));
|
||||
|
||||
expect(await service().lookup('Titan')).toEqual({ status: 'none' });
|
||||
});
|
||||
|
||||
it('does not send a browser language that is not one', async () => {
|
||||
vi.stubGlobal('navigator', { language: 'not a language tag' });
|
||||
fetchMock.mockResolvedValue(summary());
|
||||
|
||||
await service().lookup('Titan');
|
||||
|
||||
expect(languagesAsked(fetchMock)).toEqual(['en']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
/** A Wikipedia summary, as much of it as this app shows. */
|
||||
export interface Article {
|
||||
readonly title: string;
|
||||
readonly extract: string;
|
||||
/** The article itself, so a reader can go and check. */
|
||||
readonly url: string;
|
||||
/** Which Wikipedia it came from — `en`, `fr`. Shown, because it is not always the one asked for. */
|
||||
readonly language: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Found it, there is no article, or Wikipedia could not be reached. The last two are different
|
||||
* facts and the panel says so: "nothing written about this" and "could not ask" are not the
|
||||
* same, and only one of them is about the world.
|
||||
*/
|
||||
export type ArticleLookup = { readonly status: 'found'; readonly article: Article } | { readonly status: 'none' } | { readonly status: 'unavailable' };
|
||||
|
||||
const FALLBACK_LANGUAGE = 'en';
|
||||
|
||||
/** Wikipedia's own summary endpoint, which follows redirects — "Proxima Cen b" lands on
|
||||
* "Proxima Centauri b" without this app having to know that. */
|
||||
function summaryUrl(language: string, title: string): string {
|
||||
return `https://${language}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`;
|
||||
}
|
||||
|
||||
/** The primary subtag of whatever the browser is set to, or English where there is no browser. */
|
||||
function browserLanguage(): string {
|
||||
// `navigator.language` is optional in the DOM lib and absent in some embedded engines.
|
||||
const tag = (typeof navigator === 'undefined' ? '' : navigator.language) ?? '';
|
||||
const primary = tag.split('-')[0]?.toLowerCase();
|
||||
return primary && /^[a-z]{2,3}$/.test(primary) ? primary : FALLBACK_LANGUAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wikipedia summaries, fetched only when asked for.
|
||||
*
|
||||
* The map's own figures are measurements; this is prose someone wrote, from somewhere else. It
|
||||
* is never loaded with a body, only when a reader asks for it, and it is always labelled with
|
||||
* where it came from and linked back to the article — the same rule the derived surfaces follow,
|
||||
* for the same reason.
|
||||
*
|
||||
* Asked for in the browser's language first and English second, because the catalogue's names
|
||||
* are English and the disambiguation this has to work around is an English-Wikipedia habit.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ArticleService {
|
||||
private readonly cache = new Map<string, ArticleLookup>();
|
||||
|
||||
/**
|
||||
* The article for `name`, or why there is none.
|
||||
*
|
||||
* `qualifier` is what tells "Titan the moon" from "Titan the disambiguation page": Wikipedia
|
||||
* parenthesises the kind, and this app happens to know it. Only English gets that treatment —
|
||||
* every wiki words its own qualifiers — so a disambiguation anywhere else falls back to
|
||||
* English rather than guessing at a translation.
|
||||
*/
|
||||
async lookup(name: string, qualifier?: string): Promise<ArticleLookup> {
|
||||
// Separated by an escape rather than a space: a name may contain one, so `("Kepler-22 b",
|
||||
// undefined)` and `("Kepler-22", "b")` would otherwise be the same question.
|
||||
const key = `${name}\0${qualifier ?? ''}`;
|
||||
const cached = this.cache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const language = browserLanguage();
|
||||
const attempts: { language: string; title: string }[] = [
|
||||
...(language === FALLBACK_LANGUAGE ? [] : [{ language, title: name }]),
|
||||
{ language: FALLBACK_LANGUAGE, title: name },
|
||||
...(qualifier ? [{ language: FALLBACK_LANGUAGE, title: `${name} (${qualifier})` }] : [])
|
||||
];
|
||||
|
||||
let reachedWikipedia = false;
|
||||
for (const attempt of attempts) {
|
||||
const result = await this.fetchSummary(attempt.language, attempt.title);
|
||||
if (result === 'unreachable') {
|
||||
continue;
|
||||
}
|
||||
reachedWikipedia = true;
|
||||
if (result) {
|
||||
const found: ArticleLookup = { status: 'found', article: result };
|
||||
this.cache.set(key, found);
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
// Not cached when Wikipedia could not be reached: that is a fact about the network this
|
||||
// minute, and the next press should be allowed to ask again.
|
||||
const outcome: ArticleLookup = reachedWikipedia ? { status: 'none' } : { status: 'unavailable' };
|
||||
if (reachedWikipedia) {
|
||||
this.cache.set(key, outcome);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* One request. `null` where Wikipedia answered but has nothing usable — a missing page, or a
|
||||
* disambiguation, which is a list of things this is not. `'unreachable'` where it did not
|
||||
* answer at all, including the rate limit it returns to anyone who asks too fast.
|
||||
*/
|
||||
private async fetchSummary(language: string, title: string): Promise<Article | null | 'unreachable'> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(summaryUrl(language, title), { headers: { Accept: 'application/json' } });
|
||||
} catch {
|
||||
return 'unreachable';
|
||||
}
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
return 'unreachable';
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await response.json()) as {
|
||||
type?: string;
|
||||
extract?: string;
|
||||
titles?: { normalized?: string };
|
||||
content_urls?: { desktop?: { page?: string } };
|
||||
};
|
||||
const extract = body.extract?.trim();
|
||||
if (!extract || body.type === 'disambiguation') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: body.titles?.normalized ?? title,
|
||||
extract,
|
||||
url: body.content_urls?.desktop?.page ?? `https://${language}.wikipedia.org/wiki/${encodeURIComponent(title)}`,
|
||||
language
|
||||
};
|
||||
} catch {
|
||||
return 'unreachable';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { NgZone } from '@angular/core';
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { EngineService } from './engine.service';
|
||||
|
||||
/**
|
||||
* The projection half of the engine, which is the half that can be tested without a GPU: no
|
||||
* renderer is created, the two cameras are placed by hand, and what is asserted is the
|
||||
* arithmetic that keeps them showing the same thing.
|
||||
*/
|
||||
function engineWithCameras(): { engine: EngineService; perspective: THREE.PerspectiveCamera; orthographic: THREE.OrthographicCamera } {
|
||||
const engine = new EngineService({ runOutsideAngular: (fn: () => unknown) => fn() } as unknown as NgZone);
|
||||
const perspective = new THREE.PerspectiveCamera(50, 16 / 9, 0.1, 1000);
|
||||
const orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
|
||||
// The two cameras are private, because nothing outside should choose between them by hand.
|
||||
Object.assign(engine as unknown as Record<string, unknown>, { perspective, orthographic });
|
||||
return { engine, perspective, orthographic };
|
||||
}
|
||||
|
||||
/** Half the height of what a perspective camera frames at `distance`, in world units. */
|
||||
function perspectiveHalfHeight(camera: THREE.PerspectiveCamera, distance: number): number {
|
||||
return distance * Math.tan((camera.fov * Math.PI) / 360);
|
||||
}
|
||||
|
||||
describe('EngineService projection', () => {
|
||||
let engine: EngineService;
|
||||
let perspective: THREE.PerspectiveCamera;
|
||||
let orthographic: THREE.OrthographicCamera;
|
||||
|
||||
beforeEach(() => {
|
||||
({ engine, perspective, orthographic } = engineWithCameras());
|
||||
});
|
||||
|
||||
it('draws through the perspective camera until told otherwise', () => {
|
||||
expect(engine.currentProjection).toBe('perspective');
|
||||
expect(engine.getCamera()).toBe(perspective);
|
||||
});
|
||||
|
||||
it('frames the same extent through either camera, which is the point of the swap', () => {
|
||||
perspective.position.set(0, 0, 200);
|
||||
|
||||
engine.setProjection('orthographic', 200);
|
||||
|
||||
expect(engine.getCamera()).toBe(orthographic);
|
||||
expect(engine.visibleHalfHeight(200)).toBeCloseTo(perspectiveHalfHeight(perspective, 200), 6);
|
||||
// And as wide as the frame is, not as wide as it is tall.
|
||||
expect(orthographic.right - orthographic.left).toBeCloseTo((orthographic.top - orthographic.bottom) * perspective.aspect, 6);
|
||||
});
|
||||
|
||||
it('carries the pose across, so the swap changes the projection and not the view', () => {
|
||||
perspective.position.set(3, 4, 12);
|
||||
perspective.lookAt(0, 0, 0);
|
||||
|
||||
engine.setProjection('orthographic', 13);
|
||||
|
||||
expect(orthographic.position.toArray()).toEqual(perspective.position.toArray());
|
||||
expect(orthographic.quaternion.toArray()).toEqual(perspective.quaternion.toArray());
|
||||
});
|
||||
|
||||
it('sees behind itself, because a parallel camera does not back away from what it frames', () => {
|
||||
engine.setProjection('orthographic', 100);
|
||||
|
||||
// A perspective camera pulls back as its frame grows and leaves the scene in front of it. An
|
||||
// orthographic one does not move at all, so half the Galaxy ends up behind its own plane —
|
||||
// and a near plane in front would clip it away. Parallel depth is linear, so the precision
|
||||
// that a perspective near plane is guarding for does not apply.
|
||||
expect(orthographic.near).toBe(-perspective.far);
|
||||
expect(orthographic.far).toBe(perspective.far);
|
||||
});
|
||||
|
||||
it('goes back, and hands out the perspective camera again', () => {
|
||||
engine.setProjection('orthographic', 100);
|
||||
engine.setProjection('perspective', 100);
|
||||
|
||||
expect(engine.currentProjection).toBe('perspective');
|
||||
expect(engine.getCamera()).toBe(perspective);
|
||||
expect(engine.visibleHalfHeight(100)).toBeCloseTo(perspectiveHalfHeight(perspective, 100), 6);
|
||||
});
|
||||
|
||||
it('reports the extent the orthographic camera is zoomed to, not the one it was built at', () => {
|
||||
engine.setProjection('orthographic', 100);
|
||||
const framed = engine.visibleHalfHeight(100);
|
||||
|
||||
orthographic.zoom = 2;
|
||||
|
||||
// Zoomed in twice: half as much in frame. Distance says nothing about it, which is why
|
||||
// nothing downstream may read the camera's distance under this projection.
|
||||
expect(engine.visibleHalfHeight(100)).toBeCloseTo(framed / 2, 6);
|
||||
expect(engine.visibleHalfHeight(999)).toBeCloseTo(framed / 2, 6);
|
||||
});
|
||||
|
||||
it('never divides by a camera sitting on its own target', () => {
|
||||
expect(() => engine.setProjection('orthographic', 0)).not.toThrow();
|
||||
expect(Number.isFinite(orthographic.top)).toBe(true);
|
||||
});
|
||||
|
||||
it('widens rather than magnifies when the window gets wider', () => {
|
||||
engine.setProjection('orthographic', 100);
|
||||
const height = orthographic.top - orthographic.bottom;
|
||||
|
||||
// No renderer, so resize returns early — the frustum is re-fitted by hand the same way.
|
||||
orthographic.left = (-height / 2) * (21 / 9);
|
||||
orthographic.right = (height / 2) * (21 / 9);
|
||||
|
||||
expect(orthographic.top - orthographic.bottom).toBeCloseTo(height, 6);
|
||||
expect(orthographic.right - orthographic.left).toBeCloseTo(height * (21 / 9), 6);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,12 @@ import * as THREE from 'three/webgpu';
|
||||
|
||||
export type EngineTickCallback = (deltaSeconds: number, elapsedSeconds: number) => void;
|
||||
|
||||
/** Which projection the scene is drawn through. */
|
||||
export type Projection = 'perspective' | 'orthographic';
|
||||
|
||||
/** Either camera, as everything downstream of the projection sees it. */
|
||||
export type SceneCamera = THREE.PerspectiveCamera | THREE.OrthographicCamera;
|
||||
|
||||
/**
|
||||
* Owns the Three.js WebGPURenderer (with automatic WebGL2 fallback), the base scene/camera,
|
||||
* and the render loop. The loop always runs outside Angular's zone so per-frame work never
|
||||
@@ -20,7 +26,14 @@ export class EngineService {
|
||||
private canvas?: HTMLCanvasElement;
|
||||
private renderer?: THREE.WebGPURenderer;
|
||||
private scene?: THREE.Scene;
|
||||
private camera?: THREE.PerspectiveCamera;
|
||||
private perspective?: THREE.PerspectiveCamera;
|
||||
/**
|
||||
* Built alongside the perspective one and kept in step with it, rather than made on demand:
|
||||
* the two share a position, an orientation and a depth range, and a camera that only exists
|
||||
* while it is being looked through is a camera whose state is always one swap out of date.
|
||||
*/
|
||||
private orthographic?: THREE.OrthographicCamera;
|
||||
private projection: Projection = 'perspective';
|
||||
private running = false;
|
||||
|
||||
constructor(private readonly ngZone: NgZone) {}
|
||||
@@ -33,8 +46,71 @@ export class EngineService {
|
||||
return this.requireInitialized(this.scene);
|
||||
}
|
||||
|
||||
getCamera(): THREE.PerspectiveCamera {
|
||||
return this.requireInitialized(this.camera);
|
||||
/** The camera the scene is currently drawn through. */
|
||||
getCamera(): SceneCamera {
|
||||
return this.projection === 'orthographic' ? this.requireInitialized(this.orthographic) : this.requireInitialized(this.perspective);
|
||||
}
|
||||
|
||||
/**
|
||||
* The perspective camera, whichever is active. For the handful of places that need a field of
|
||||
* view to reason with — framing a system, sizing a star — and that go on meaning the same
|
||||
* thing in either projection because the sizes were tuned against this one.
|
||||
*/
|
||||
getPerspectiveCamera(): THREE.PerspectiveCamera {
|
||||
return this.requireInitialized(this.perspective);
|
||||
}
|
||||
|
||||
get currentProjection(): Projection {
|
||||
return this.projection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches projection, carrying the pose across. The orthographic frustum is sized to show
|
||||
* the same extent at `distanceToTarget` that the perspective camera showed from there, so the
|
||||
* swap changes how the scene is projected and not how much of it is in frame.
|
||||
*/
|
||||
setProjection(projection: Projection, distanceToTarget: number): void {
|
||||
const perspective = this.requireInitialized(this.perspective);
|
||||
const orthographic = this.requireInitialized(this.orthographic);
|
||||
this.projection = projection;
|
||||
|
||||
orthographic.zoom = 1;
|
||||
orthographic.position.copy(perspective.position);
|
||||
orthographic.quaternion.copy(perspective.quaternion);
|
||||
this.frameOrthographic(distanceToTarget);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sizes the orthographic frustum to show, at `distanceToTarget`, what the perspective camera
|
||||
* would show from there. Called every frame while that projection is active, which is what
|
||||
* makes the camera flights work through it: they move the camera, and the frame follows.
|
||||
*
|
||||
* The depth range is symmetric about the camera rather than starting in front of it. An
|
||||
* orthographic camera does not pull back as its frame grows, so at galactic framing the
|
||||
* backdrop shell and half the Milky Way lie behind its own plane and would be clipped away.
|
||||
* A parallel projection has linear depth, so the precision argument that makes a perspective
|
||||
* near plane worth guarding does not apply here.
|
||||
*/
|
||||
frameOrthographic(distanceToTarget: number): void {
|
||||
const perspective = this.requireInitialized(this.perspective);
|
||||
const orthographic = this.requireInitialized(this.orthographic);
|
||||
const halfHeight = Math.max(distanceToTarget, 1e-6) * Math.tan((perspective.fov * Math.PI) / 360);
|
||||
orthographic.top = halfHeight;
|
||||
orthographic.bottom = -halfHeight;
|
||||
orthographic.left = -halfHeight * perspective.aspect;
|
||||
orthographic.right = halfHeight * perspective.aspect;
|
||||
orthographic.far = perspective.far;
|
||||
orthographic.near = -perspective.far;
|
||||
orthographic.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
/** Half the height of what is in frame at the target, in world units, under either camera. */
|
||||
visibleHalfHeight(distanceToTarget: number): number {
|
||||
if (this.projection === 'orthographic') {
|
||||
const orthographic = this.requireInitialized(this.orthographic);
|
||||
return (orthographic.top - orthographic.bottom) / (2 * orthographic.zoom);
|
||||
}
|
||||
return distanceToTarget * Math.tan((this.requireInitialized(this.perspective).fov * Math.PI) / 360);
|
||||
}
|
||||
|
||||
getRenderer(): THREE.WebGPURenderer {
|
||||
@@ -52,8 +128,10 @@ export class EngineService {
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
|
||||
this.scene = new THREE.Scene();
|
||||
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
|
||||
this.camera.position.set(0, 0, 5);
|
||||
this.perspective = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
|
||||
this.perspective.position.set(0, 0, 5);
|
||||
this.orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
|
||||
this.orthographic.position.copy(this.perspective.position);
|
||||
|
||||
const { width, height } = this.canvasSize();
|
||||
this.resize(width, height);
|
||||
@@ -99,11 +177,18 @@ export class EngineService {
|
||||
* Updates the camera aspect ratio and renderer drawing buffer size.
|
||||
*/
|
||||
resize(width: number, height: number): void {
|
||||
if (!this.renderer || !this.camera || width <= 0 || height <= 0) {
|
||||
if (!this.renderer || !this.perspective || !this.orthographic || width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
this.camera.aspect = width / height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
const aspect = width / height;
|
||||
this.perspective.aspect = aspect;
|
||||
this.perspective.updateProjectionMatrix();
|
||||
// The orthographic frustum keeps its height and re-fits its width, so a window getting wider
|
||||
// shows more to the sides rather than magnifying what was already there.
|
||||
const halfHeight = (this.orthographic.top - this.orthographic.bottom) / 2;
|
||||
this.orthographic.left = -halfHeight * aspect;
|
||||
this.orthographic.right = halfHeight * aspect;
|
||||
this.orthographic.updateProjectionMatrix();
|
||||
this.renderer.setSize(width, height, false);
|
||||
}
|
||||
|
||||
@@ -116,7 +201,8 @@ export class EngineService {
|
||||
this.renderer?.dispose();
|
||||
this.renderer = undefined;
|
||||
this.scene = undefined;
|
||||
this.camera = undefined;
|
||||
this.perspective = undefined;
|
||||
this.orthographic = undefined;
|
||||
this.canvas = undefined;
|
||||
}
|
||||
|
||||
@@ -128,7 +214,7 @@ export class EngineService {
|
||||
callback(deltaSeconds, elapsedSeconds);
|
||||
}
|
||||
|
||||
this.requireInitialized(this.renderer).render(this.requireInitialized(this.scene), this.requireInitialized(this.camera));
|
||||
this.requireInitialized(this.renderer).render(this.requireInitialized(this.scene), this.getCamera());
|
||||
}
|
||||
|
||||
private canvasSize(): { width: number; height: number } {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DataLoaderService, StarField } from '../../core/data/data-loader.service';
|
||||
import { EngineService, EngineTickCallback } from '../../core/engine/engine.service';
|
||||
import { BodyRecord } from '../../shared/models/body.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { bodyPageView } from '../../shared/rendering/body-orientation';
|
||||
import { TimeStore } from '../../shared/state/time.store';
|
||||
import { BodyDetailSceneComponent } from './body-detail-scene.component';
|
||||
|
||||
// jsdom has no ResizeObserver; the page only uses it to follow real layout changes.
|
||||
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver ??= class {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
};
|
||||
|
||||
const SUN: StarRecord = { id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 };
|
||||
|
||||
// Earth and Saturn as bodies.json carries them: Standish's elements and the IAU's.
|
||||
const EARTH: BodyRecord = {
|
||||
id: 'earth', systemStarId: 0, name: 'Earth', kind: 'planet', radiusKm: 6371, orbitSource: 'test',
|
||||
orbit: {semiMajorAxisAu: 1.00000018, eccentricity: 0.01673163, inclinationDeg: -0.00054346, longitudeOfAscendingNodeDeg: -5.11260389, argumentOfPeriapsisDeg: 108.04266274, meanAnomalyAtEpochDeg: -2.4631431299999917, epochJd: 2451545},
|
||||
rates: {meanMotionDegPerDay: 0.9856091187759068, longitudeOfAscendingNodeDegPerDay: -0.000006604751813826146, argumentOfPeriapsisDegPerDay: 0.000015309819575633124},
|
||||
rotationalElements: {poleRaDeg: [0, -0.641, 0], poleDecDeg: [90, -0.557, 0], primeMeridianDeg: [190.147, 360.9856235, 0]}
|
||||
};
|
||||
const SATURN: BodyRecord = {
|
||||
id: 'saturn', systemStarId: 0, name: 'Saturn', kind: 'planet', radiusKm: 58232, orbitSource: 'test',
|
||||
orbit: {semiMajorAxisAu: 9.54149883, eccentricity: 0.05550825, inclinationDeg: 2.49424102, longitudeOfAscendingNodeDeg: 113.63998702, argumentOfPeriapsisDeg: -20.778626390000014, meanAnomalyAtEpochDeg: -42.78564733999999, epochJd: 2451545},
|
||||
rates: {meanMotionDegPerDay: 0.033459683702669406, longitudeOfAscendingNodeDegPerDay: -0.000006848734291581108, argumentOfPeriapsisDegPerDay: 0.000021682266940451745},
|
||||
rotationalElements: {poleRaDeg: [40.589, -0.036, 0], poleDecDeg: [83.537, -0.004, 0], primeMeridianDeg: [38.9, 810.7939024, 0]}
|
||||
};
|
||||
// Eris and Hyperion as they are shipped for this page's purposes: no IAU model, so their pages keep
|
||||
// their own light; Eris's day is measured, and Hyperion tumbles and has none.
|
||||
const ERIS: BodyRecord = { ...EARTH, id: 'eris', name: 'Eris', kind: 'dwarf', radiusKm: 1163, rotationalElements: undefined, rotationPeriodHours: 378.504 };
|
||||
const HYPERION: BodyRecord = { ...ERIS, id: 'hyperion', name: 'Hyperion', kind: 'moon', radiusKm: 135, parentBodyId: 'saturn', rotationPeriodHours: undefined };
|
||||
// Mercury as shipped, but its 0.01-degree libration: its Sun is never 0.034 degrees off its equator.
|
||||
const MERCURY: BodyRecord = {
|
||||
id: 'mercury', systemStarId: 0, name: 'Mercury', kind: 'planet', radiusKm: 2439.4, orbitSource: 'test',
|
||||
orbit: {semiMajorAxisAu: 0.38709843, eccentricity: 0.20563661, inclinationDeg: 7.00559432, longitudeOfAscendingNodeDeg: 48.33961819, argumentOfPeriapsisDeg: 29.118100759999997, meanAnomalyAtEpochDeg: 174.79394829, epochJd: 2451545},
|
||||
rates: {meanMotionDegPerDay: 4.092338805372484, longitudeOfAscendingNodeDegPerDay: -0.0000033440607802874744, argumentOfPeriapsisDegPerDay: 0.000007708198494182067},
|
||||
rotationalElements: {poleRaDeg: [281.0103, -0.0328, 0], poleDecDeg: [61.4155, -0.0049, 0], primeMeridianDeg: [329.5988, 6.1385108, 0]}
|
||||
};
|
||||
const BODIES = [EARTH, SATURN, ERIS, HYPERION, MERCURY];
|
||||
// An exoplanet round the Sun's record, which is all the page needs of its host.
|
||||
const EXOPLANET: ExoplanetRecord = { id: 'x b', hostStarId: 0, hostStarName: 'Sol', name: 'X b', orbit: { semiMajorAxisAu: 0.05 } };
|
||||
|
||||
/** Stands in for the WebGPU engine: a scene, a camera, and the tick hook, driven by hand. */
|
||||
class FakeEngineService {
|
||||
private readonly scene = new THREE.Scene();
|
||||
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
|
||||
private readonly callbacks = new Set<EngineTickCallback>();
|
||||
|
||||
async init(): Promise<void> {}
|
||||
getScene(): THREE.Scene {
|
||||
return this.scene;
|
||||
}
|
||||
getCamera(): THREE.PerspectiveCamera {
|
||||
return this.camera;
|
||||
}
|
||||
onTick(callback: EngineTickCallback): () => void {
|
||||
this.callbacks.add(callback);
|
||||
return () => this.callbacks.delete(callback);
|
||||
}
|
||||
start(): void {}
|
||||
resize(): void {}
|
||||
dispose(): void {}
|
||||
tick(deltaSeconds: number): void {
|
||||
for (const callback of this.callbacks) {
|
||||
callback(deltaSeconds, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDataLoaderService {
|
||||
loadStars(): Promise<StarField> {
|
||||
return Promise.resolve({ stars: [SUN], positions: new Float32Array([0, 0, 0]) });
|
||||
}
|
||||
loadBodies(): Promise<BodyRecord[]> {
|
||||
return Promise.resolve(BODIES);
|
||||
}
|
||||
loadExoplanets(): Promise<ExoplanetRecord[]> {
|
||||
return Promise.resolve([EXOPLANET]);
|
||||
}
|
||||
}
|
||||
|
||||
async function flushAsync(turns = 8): Promise<void> {
|
||||
for (let i = 0; i < turns; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
describe('BodyDetailSceneComponent', () => {
|
||||
let engine: FakeEngineService;
|
||||
let page: { planet: THREE.Mesh; ring?: THREE.Mesh; sunLight: THREE.DirectionalLight };
|
||||
let time: TimeStore;
|
||||
let route: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||
|
||||
let fixture: ComponentFixture<BodyDetailSceneComponent>;
|
||||
|
||||
/** Opens a body's page at a date; `whole` keeps the page's own template, dock and panel included. */
|
||||
async function open(id: string, date = '2025-06-01T12:00Z', whole = false): Promise<void> {
|
||||
engine = new FakeEngineService();
|
||||
route = new BehaviorSubject(convertToParamMap({ id }));
|
||||
TestBed.configureTestingModule({
|
||||
imports: [BodyDetailSceneComponent],
|
||||
providers: [
|
||||
{ provide: DataLoaderService, useClass: FakeDataLoaderService },
|
||||
{ provide: ActivatedRoute, useValue: { paramMap: route } },
|
||||
{ provide: Router, useValue: { navigate: vi.fn().mockResolvedValue(true) } }
|
||||
]
|
||||
}).overrideComponent(BodyDetailSceneComponent, {
|
||||
// The scene alone, unless asked: the info panel and the dock are tested on their own.
|
||||
set: whole ? { providers: [{ provide: EngineService, useValue: engine }] } : { providers: [{ provide: EngineService, useValue: engine }], imports: [], template: '<canvas #canvas></canvas>' }
|
||||
});
|
||||
time = TestBed.inject(TimeStore);
|
||||
time.setRate(0);
|
||||
time.setDate(new Date(date));
|
||||
fixture = TestBed.createComponent(BodyDetailSceneComponent);
|
||||
fixture.detectChanges();
|
||||
await flushAsync();
|
||||
page = fixture.componentInstance as unknown as typeof page;
|
||||
engine.tick(0.016);
|
||||
}
|
||||
|
||||
beforeEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('lays Saturn’s rings in its equator on the page, where audit #47 found them 17 degrees off it', async () => {
|
||||
await open('saturn');
|
||||
const ring = page.ring!;
|
||||
ring.updateWorldMatrix(true, false);
|
||||
const normal = new THREE.Vector3().fromBufferAttribute(ring.geometry.attributes['normal'], 0).transformDirection(ring.matrixWorld);
|
||||
const pole = new THREE.Vector3(0, 1, 0).applyQuaternion(page.planet.quaternion);
|
||||
expect(normal.angleTo(pole)).toBeLessThan(1e-6);
|
||||
});
|
||||
|
||||
it('turns Earth on its page as it stands at the map’s date, under its real Sun', async () => {
|
||||
await open('earth');
|
||||
const planet = new THREE.Quaternion();
|
||||
const sun = new THREE.Vector3();
|
||||
expect(bodyPageView(EARTH, BODIES, time.julianDate(), Math.atan2(4, 5), planet, sun)).toBe(true);
|
||||
expect(page.planet.quaternion.angleTo(planet)).toBeLessThan(1e-9);
|
||||
expect(page.sunLight.position.clone().normalize().angleTo(sun)).toBeLessThan(1e-9);
|
||||
});
|
||||
|
||||
it('follows the clock once the page is open, as it runs or is set', async () => {
|
||||
await open('earth');
|
||||
time.setDate(new Date('2025-06-01T18:00Z'));
|
||||
engine.tick(0.016);
|
||||
const planet = new THREE.Quaternion();
|
||||
expect(bodyPageView(EARTH, BODIES, time.julianDate(), Math.atan2(4, 5), planet, new THREE.Vector3())).toBe(true);
|
||||
// Six hours on, a quarter turn of Earth: a page frozen at its first frame is 90 degrees out.
|
||||
expect(page.planet.quaternion.angleTo(planet)).toBeLessThan(1e-9);
|
||||
});
|
||||
|
||||
it('puts the page’s own light back when the next body shown has no IAU model to place its Sun', async () => {
|
||||
await open('earth');
|
||||
expect(page.sunLight.position.distanceTo(new THREE.Vector3(4, 3, 5))).toBeGreaterThan(0.1);
|
||||
route.next(convertToParamMap({ id: 'eris' }));
|
||||
await flushAsync();
|
||||
engine.tick(0.016);
|
||||
expect(page.sunLight.position.distanceTo(new THREE.Vector3(4, 3, 5))).toBeLessThan(1e-9);
|
||||
});
|
||||
|
||||
it('puts the sphere back at rest when the next body shown does not turn: Hyperion after Earth', async () => {
|
||||
await open('earth');
|
||||
expect(page.planet.quaternion.angleTo(new THREE.Quaternion())).toBeGreaterThan(0.1);
|
||||
route.next(convertToParamMap({ id: 'hyperion' }));
|
||||
await flushAsync();
|
||||
engine.tick(0.016);
|
||||
engine.tick(0.016);
|
||||
expect(page.planet.quaternion.angleTo(new THREE.Quaternion())).toBeLessThan(1e-9);
|
||||
});
|
||||
|
||||
it('turns a body whose day is measured but not its pole at that day on the map’s clock: Eris a sixth of a turn in 63.084 hours', async () => {
|
||||
await open('eris');
|
||||
const start = page.planet.rotation.y;
|
||||
// The clock stands (the page is opened at rate 0): so does Eris, where it used to turn for show.
|
||||
engine.tick(1);
|
||||
expect(page.planet.rotation.y).toBe(start);
|
||||
time.setDate(new Date(Date.parse('2025-06-01T12:00Z') + (378.504 / 6) * 3600000));
|
||||
engine.tick(0.016);
|
||||
const turned = (((page.planet.rotation.y - start) / (2 * Math.PI)) % 1 + 1) % 1;
|
||||
expect(turned).toBeCloseTo(1 / 6, 6);
|
||||
// Pole up, as the system view turns it about its orbit's normal.
|
||||
expect(new THREE.Vector3(0, 1, 0).applyQuaternion(page.planet.quaternion).y).toBeCloseTo(1, 12);
|
||||
});
|
||||
|
||||
it('turns an exoplanet slowly for show, clock or no clock: the catalogue carries no day for it', async () => {
|
||||
await open('x b');
|
||||
const start = page.planet.rotation.y;
|
||||
// The clock stands; a second of the page's own time is 0.08 radians.
|
||||
engine.tick(1);
|
||||
expect(page.planet.rotation.y - start).toBeCloseTo(0.08, 12);
|
||||
});
|
||||
|
||||
it('says on its dock the date the body is drawn for, and nothing at the present, and offers the clock', async () => {
|
||||
await open('saturn', '2032-06-01T12:00Z', true);
|
||||
fixture.detectChanges();
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
expect(host.querySelector('[data-testid="hud-date"]')?.textContent).toContain('2032-06-01');
|
||||
expect([...host.querySelectorAll('[role="tab"]')].map((tab) => tab.textContent?.trim())).toContain('Clock');
|
||||
time.reset();
|
||||
engine.tick(0.016);
|
||||
fixture.detectChanges();
|
||||
expect(host.querySelector('[data-testid="hud-date"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens Saturn on the face of its rings the Sun lights: the south, from 2025 to 2039', async () => {
|
||||
await open('saturn', '2032-06-01T12:00Z');
|
||||
const camera = engine.getCamera();
|
||||
// The Sun 26.7 degrees south of the rings, and the camera with it rather than 11 degrees north.
|
||||
expect(page.sunLight.position.y).toBeLessThan(0);
|
||||
expect(camera.position.y).toBeLessThan(0);
|
||||
|
||||
route.next(convertToParamMap({ id: 'earth' }));
|
||||
await flushAsync();
|
||||
engine.tick(0.016);
|
||||
// June: Earth's Sun is in the north, and so is the camera again.
|
||||
expect(page.sunLight.position.y).toBeGreaterThan(0);
|
||||
expect(camera.position.y).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('follows the Sun across Saturn’s equator when the clock is set past the 2039 equinox, and aims at Saturn in that same frame', async () => {
|
||||
await open('saturn', '2032-06-01T12:00Z');
|
||||
const camera = engine.getCamera();
|
||||
expect(camera.position.y).toBeLessThan(0);
|
||||
// What the page's own Clock tab does: 2045, the Sun 25.7 degrees north of the rings.
|
||||
time.setDate(new Date('2045-06-01T12:00Z'));
|
||||
engine.tick(0.016);
|
||||
expect(page.sunLight.position.y).toBeGreaterThan(0);
|
||||
expect(camera.position.y).toBeGreaterThan(0);
|
||||
// The frame drawn straight after the move: aimed from where the camera was, it had Saturn 22.6
|
||||
// degrees off the middle of the view.
|
||||
const toSaturn = new THREE.Vector3().sub(camera.position);
|
||||
expect(camera.getWorldDirection(new THREE.Vector3()).angleTo(toSaturn)).toBeLessThan(1e-9);
|
||||
});
|
||||
|
||||
it('opens the next body shown on its own Sun’s side, wherever the reader left the camera: Earth after Saturn in December', async () => {
|
||||
await open('saturn', '2032-12-01T12:00Z');
|
||||
const camera = engine.getCamera();
|
||||
expect(camera.position.y).toBeLessThan(0);
|
||||
// Taken north by the reader, over Saturn's unlit ring face.
|
||||
camera.position.y = 0.6;
|
||||
engine.tick(0.016);
|
||||
expect(camera.position.y).toBeGreaterThan(0);
|
||||
|
||||
route.next(convertToParamMap({ id: 'earth' }));
|
||||
await flushAsync();
|
||||
engine.tick(0.016);
|
||||
// December: Earth's Sun is south, as Saturn's was, so only the side chosen afresh moves the camera.
|
||||
expect(page.sunLight.position.y).toBeLessThan(0);
|
||||
expect(camera.position.y).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('leaves the camera on its side while the Sun only grazes the equator: Mercury through two crossings', async () => {
|
||||
// The Sun is south of Mercury's equator on 2026-10-20, north from about 1 November, and south
|
||||
// again from about 6 December, never more than 0.034 degrees either side.
|
||||
await open('mercury', '2026-10-20T00:00Z');
|
||||
const camera = engine.getCamera();
|
||||
expect(page.sunLight.position.y).toBeLessThan(0);
|
||||
expect(camera.position.y).toBeLessThan(0);
|
||||
const sunSides = new Set<number>();
|
||||
for (let day = 1; day <= 60; day++) {
|
||||
time.setDate(new Date(Date.parse('2026-10-20T00:00Z') + day * 86400000));
|
||||
engine.tick(0.016);
|
||||
sunSides.add(Math.sign(page.sunLight.position.y));
|
||||
expect(camera.position.y).toBeLessThan(0);
|
||||
}
|
||||
expect([...sunSides].sort()).toEqual([-1, 1]);
|
||||
});
|
||||
|
||||
it('leaves the camera where the reader orbits it while the Sun stays on one side', async () => {
|
||||
await open('saturn', '2032-06-01T12:00Z');
|
||||
const camera = engine.getCamera();
|
||||
// Taken over the rings, to their unlit face, on purpose.
|
||||
camera.position.y = 0.6;
|
||||
engine.tick(0.016);
|
||||
expect(camera.position.y).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,23 @@
|
||||
import { AfterViewInit, Component, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||
import { EngineService } from '../../core/engine/engine.service';
|
||||
import { bodyPageView } from '../../shared/rendering/body-orientation';
|
||||
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
||||
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 { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { Bookmark } from '../../shared/state/bookmarks.store';
|
||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
import { TimeStore } from '../../shared/state/time.store';
|
||||
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
||||
import { HudDockComponent } from '../hud/hud-dock.component';
|
||||
import { BodyDetailViewModel } from './body-detail.model';
|
||||
import { buildBodyViewModel } from './body-view-model';
|
||||
import { InfoPanelComponent } from './info-panel.component';
|
||||
@@ -22,6 +26,18 @@ import { InfoPanelComponent } from './info-panel.component';
|
||||
const GAS_GIANT_IDS = new Set(['jupiter', 'saturn', 'uranus', 'neptune']);
|
||||
/** The body is drawn at unit radius here, so the halo's extent is its multiple directly. */
|
||||
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
|
||||
@@ -37,14 +53,14 @@ const GLOW_SCALE = 2.6;
|
||||
@Component({
|
||||
selector: 'app-body-detail-scene',
|
||||
providers: [EngineService],
|
||||
imports: [ChevronIconComponent, InfoPanelComponent, RouterLink],
|
||||
imports: [ChevronIconComponent, HudDockComponent, InfoPanelComponent, RouterLink],
|
||||
template: `
|
||||
<div class="relative h-full w-full">
|
||||
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
|
||||
@if (viewModel()) {
|
||||
<app-info-panel [body]="viewModel()!" />
|
||||
} @else if (notFound()) {
|
||||
<div class="hud-brackets hud-acquire hud-surface absolute top-20 right-4 w-80 max-w-[calc(100%-2rem)] p-4 font-body text-text xl:top-4">
|
||||
<div class="hud-brackets hud-acquire hud-surface absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] p-4 font-body text-text">
|
||||
<p class="type-eyebrow text-accent">No record</p>
|
||||
<p class="mt-2 text-sm text-muted">That id isn't in the catalog — it may have been renamed or mistyped.</p>
|
||||
<a
|
||||
@@ -56,16 +72,35 @@ const GLOW_SCALE = 2.6;
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
<!-- Search, what has been kept and the clock: there is no scene readout here, the info
|
||||
panel is the reading, and the panel's own control is what keeps this body. A solar-system
|
||||
body is drawn at the clock's date and turns at its rate, so both are shown and can be set
|
||||
here; an exoplanet, whose day the catalogue does not carry, turns for show whatever the
|
||||
clock says. -->
|
||||
<app-hud-dock [date]="date()" [clock]="true" (bookmarkChosen)="goToBookmark($event)" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||
/** A kept place, revisited from this page: a star means leaving it for the map. */
|
||||
goToBookmark(bookmark: Bookmark): void {
|
||||
if (bookmark.kind === 'star') {
|
||||
this.navigationStore.selectStar(Number(bookmark.id));
|
||||
void this.router.navigate(['/']);
|
||||
} else {
|
||||
void this.router.navigate(['/body', String(bookmark.id)]);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
|
||||
|
||||
private controls?: OrbitControls;
|
||||
private scene?: THREE.Scene;
|
||||
private planet?: THREE.Mesh;
|
||||
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 glow?: THREE.Sprite;
|
||||
private resizeObserver?: ResizeObserver;
|
||||
@@ -79,12 +114,18 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
readonly viewModel = signal<BodyDetailViewModel | undefined>(undefined);
|
||||
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(
|
||||
private readonly engine: EngineService,
|
||||
private readonly dataLoader: DataLoaderService,
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly navigationStore: NavigationStore
|
||||
private readonly router: Router,
|
||||
private readonly navigationStore: NavigationStore,
|
||||
private readonly time: TimeStore
|
||||
) {}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
@@ -151,8 +192,8 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
// Real photography wherever it exists, and a surface derived from the body's own measured
|
||||
// properties wherever it does not — which is every exoplanet, since none has ever been
|
||||
// imaged, and the handful of moons no probe returned a usable map of.
|
||||
// properties wherever it does not — which is every exoplanet, since none has had its
|
||||
// surface imaged, and the handful of moons no probe returned a usable map of.
|
||||
const realTexturePath = bodyTexturePath(viewModel.id);
|
||||
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.
|
||||
@@ -160,12 +201,19 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||
// A fluid envelope scatters light more evenly than a solid surface does.
|
||||
this.planetMaterial.roughness = GAS_GIANT_IDS.has(viewModel.id) || viewModel.appearance.palette.structure === 'banded' ? 0.55 : 0.85;
|
||||
this.planetMaterial.needsUpdate = true;
|
||||
// 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.disposeGlow();
|
||||
if (this.scene) {
|
||||
if (viewModel.id === 'saturn') {
|
||||
this.ring = this.buildSaturnRing();
|
||||
if (viewModel.id === 'saturn' && this.body) {
|
||||
// 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);
|
||||
}
|
||||
const atmosphereColor = atmosphereColorFor(viewModel.id);
|
||||
@@ -176,37 +224,6 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saturn's rings, built from a real ring-transparency map. `RingGeometry`'s default UVs wrap
|
||||
* around the angle rather than the radius, so the per-vertex U is remapped to distance from
|
||||
* center — the standard fix for sampling a radially-varying ring texture correctly.
|
||||
*/
|
||||
private buildSaturnRing(): THREE.Mesh {
|
||||
const geometry = new THREE.RingGeometry(1.4, 2.6, 128, 1);
|
||||
const position = geometry.attributes['position'];
|
||||
const uv = geometry.attributes['uv'];
|
||||
const vertex = new THREE.Vector3();
|
||||
for (let i = 0; i < position.count; i++) {
|
||||
vertex.fromBufferAttribute(position, i);
|
||||
const radialFraction = THREE.MathUtils.clamp((vertex.length() - 1.4) / (2.6 - 1.4), 0, 1);
|
||||
uv.setXY(i, radialFraction, 1);
|
||||
}
|
||||
|
||||
const ringTexture = loadCachedTexture(SATURN_RING_TEXTURE_PATH);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: ringTexture,
|
||||
alphaMap: ringTexture,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false
|
||||
});
|
||||
|
||||
const ring = new THREE.Mesh(geometry, material);
|
||||
ring.rotation.x = Math.PI / 2 - THREE.MathUtils.degToRad(17);
|
||||
return ring;
|
||||
}
|
||||
|
||||
private disposeRing(): void {
|
||||
if (!this.ring) {
|
||||
return;
|
||||
@@ -252,9 +269,9 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.controls.maxDistance = 12;
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
|
||||
const sunLight = new THREE.DirectionalLight(0xfff4e0, 1.6);
|
||||
sunLight.position.set(4, 3, 5);
|
||||
scene.add(sunLight);
|
||||
this.sunLight = new THREE.DirectionalLight(0xfff4e0, 1.6);
|
||||
this.sunLight.position.copy(SUN_LIGHT_POSITION);
|
||||
scene.add(this.sunLight);
|
||||
|
||||
const geometry = new THREE.SphereGeometry(1, 64, 48);
|
||||
const viewModel = this.viewModel();
|
||||
@@ -273,11 +290,46 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -20,22 +20,25 @@ export interface BodyDetailViewModel {
|
||||
*/
|
||||
hostStarId?: 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;
|
||||
discoveryYear?: number;
|
||||
orbit: Partial<OrbitalElements>;
|
||||
/**
|
||||
* What this world is inferred to look like, and the quantities that inference rests on. Always
|
||||
* present — every body has measurements enough to place it somewhere — but its individual
|
||||
* fields are nullable, since a body whose host star is not in the catalogue has no derived
|
||||
* temperature.
|
||||
* fields are nullable, since a body whose host star's luminosity or whose orbit's size is not
|
||||
* known has no derived temperature.
|
||||
*/
|
||||
appearance: PlanetAppearance;
|
||||
/** True when a real photograph is being shown rather than the derived surface. */
|
||||
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
|
||||
* semi-major axis for heliocentric orbits, where the central mass is known exactly. Undefined
|
||||
* when neither applies — see `heliocentricPeriodDays`.
|
||||
* Sidereal orbital period. For a solar-system body, 360 degrees over JPL's published mean
|
||||
* motion; for an exoplanet, the archive's period where it published one, and undefined where not.
|
||||
*/
|
||||
orbitalPeriodDays?: number;
|
||||
/**
|
||||
@@ -44,4 +47,6 @@ export interface BodyDetailViewModel {
|
||||
* derived surface as a photograph.
|
||||
*/
|
||||
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 {
|
||||
const measured: Readout[] = [];
|
||||
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) {
|
||||
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) });
|
||||
}
|
||||
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 —
|
||||
// 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) });
|
||||
}
|
||||
|
||||
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
|
||||
* 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 {
|
||||
if (body.hasPhotography) {
|
||||
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
|
||||
? 'Surface illustrated from this body’s measured size and mass. Its host star is not in the catalogue, so no temperature could be derived. Not an observation — no image of this world exists.'
|
||||
: 'Surface illustrated from the measurements above — size, density and the temperature derived from its star’s output and its orbit. Not an observation — no image of this world exists.';
|
||||
? `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 — ${why}.`;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.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 => ({
|
||||
semiMajorAxisAu: 1,
|
||||
@@ -34,6 +38,9 @@ const earth: BodyRecord = {
|
||||
kind: 'planet',
|
||||
radiusKm: 6371,
|
||||
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 = {
|
||||
id: 'luna',
|
||||
@@ -43,45 +50,74 @@ const luna: BodyRecord = {
|
||||
radiusKm: 1737,
|
||||
parentBodyId: 'earth',
|
||||
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', () => {
|
||||
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);
|
||||
expect(model?.orbitalPeriodSource).toBe('derived');
|
||||
expect(model?.orbitalPeriodDays).toBeCloseTo(365.25, 1);
|
||||
expect(model?.orbitalPeriodSource).toBe('measured');
|
||||
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);
|
||||
expect(model?.orbitalPeriodDays).toBeUndefined();
|
||||
expect(model?.orbitalPeriodSource).toBeUndefined();
|
||||
expect(model?.orbitalPeriodSource).toBe('measured');
|
||||
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', () => {
|
||||
@@ -122,3 +158,77 @@ describe('buildBodyViewModel', () => {
|
||||
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 { 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 { BodyRecord } from '../../shared/models/body.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
|
||||
* 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 {
|
||||
if (!star) {
|
||||
return null;
|
||||
return star && (star.magnitudeBand || star.id === SUN_STAR_ID) ? luminositySolar(photometryOf(star)) : null;
|
||||
}
|
||||
return luminositySolar({
|
||||
|
||||
function photometryOf(star: StarRecord): StellarPhotometry {
|
||||
return {
|
||||
magnitude: star.magnitude,
|
||||
distancePc: Math.hypot(star.x, star.y, star.z),
|
||||
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);
|
||||
if (body) {
|
||||
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 {
|
||||
id: body.id,
|
||||
name: body.name,
|
||||
@@ -49,11 +123,13 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body
|
||||
hostStarName: hostStar?.name ?? 'Unknown star',
|
||||
hostStarId: body.systemStarId,
|
||||
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)),
|
||||
hasPhotography: bodyTexturePath(body.id) !== undefined,
|
||||
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,
|
||||
discoveryYear: exoplanet.discoveryYear,
|
||||
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,
|
||||
imaged: exoplanet.imaged,
|
||||
// `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, 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',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import { Component, computed, effect, inject, input, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { Article, ArticleService } from '../../core/data/article.service';
|
||||
import { BookmarksStore } from '../../shared/state/bookmarks.store';
|
||||
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
|
||||
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
||||
import { bodyReadouts } from './body-readouts';
|
||||
import { BodyDetailViewModel } from './body-detail.model';
|
||||
@@ -17,12 +20,10 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-info-panel',
|
||||
imports: [ChevronIconComponent, ReadoutSectionsComponent],
|
||||
imports: [BookmarkIconComponent, ChevronIconComponent, ReadoutSectionsComponent],
|
||||
template: `
|
||||
<!-- Sits below the search field until xl, beside it from there. The search field is 26rem
|
||||
wide and centred, so this right-anchored 20rem panel only clears it once the viewport
|
||||
passes ~1088px — at sm they still overlap and the search would cover the back button. -->
|
||||
<div class="hud-brackets hud-acquire hud-surface absolute top-20 right-4 w-80 max-w-[calc(100%-2rem)] font-body text-text xl:top-4">
|
||||
<!-- Top-right, clear of the dock along the bottom; nothing else shares the top edge here. -->
|
||||
<div class="hud-brackets hud-acquire hud-surface absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] font-body text-text">
|
||||
<button
|
||||
type="button"
|
||||
(click)="goBack()"
|
||||
@@ -32,21 +33,107 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
|
||||
System
|
||||
</button>
|
||||
|
||||
<header class="px-4 pt-4 pb-3">
|
||||
<h1 class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</h1>
|
||||
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||
<header class="flex items-start gap-2 px-4 pt-4 pb-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- Wrapped, not truncated: a designation's last digits are the ones that tell it from its
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
[attr.aria-label]="(bookmarks.has('body', body().id) ? 'Forget ' : 'Keep ') + body().name"
|
||||
[attr.aria-pressed]="bookmarks.has('body', body().id)"
|
||||
(click)="bookmarks.toggle({ kind: 'body', id: body().id, name: body().name })"
|
||||
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
[class]="bookmarks.has('body', body().id) ? 'text-accent' : 'text-muted hover:text-accent'"
|
||||
>
|
||||
<app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('body', body().id)" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<app-readout-sections [readouts]="readouts()" />
|
||||
|
||||
<!-- Prose from elsewhere, and only when asked for. Everything above this line is a
|
||||
measurement or something derived from one; this is a person's paragraph on another
|
||||
site, so it says whose and links back to it. -->
|
||||
@if (article(); as found) {
|
||||
<!-- Capped and scrollable: a Wikipedia lead can run to a dozen lines, and this panel is
|
||||
anchored to the top of a viewport that may be a good deal shorter than the prose. -->
|
||||
<div class="max-h-56 overflow-y-auto border-t border-border/40 px-4 py-3">
|
||||
<p class="text-[11px] leading-relaxed text-muted">{{ found.extract }}</p>
|
||||
<a
|
||||
[href]="found.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="type-label mt-2 inline-block text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent"
|
||||
>Wikipedia · {{ found.language }}</a
|
||||
>
|
||||
</div>
|
||||
} @else if (aboutState() !== 'idle') {
|
||||
<p class="border-t border-border/40 px-4 py-3 text-[11px] leading-relaxed text-muted">
|
||||
@switch (aboutState()) {
|
||||
@case ('loading') {
|
||||
Asking Wikipedia…
|
||||
}
|
||||
@case ('none') {
|
||||
Wikipedia has no article on {{ body().name }}.
|
||||
}
|
||||
@case ('unavailable') {
|
||||
Wikipedia could not be reached.
|
||||
<button type="button" (click)="loadArticle()" class="text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent">Try again</button>
|
||||
}
|
||||
}
|
||||
</p>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
(click)="loadArticle()"
|
||||
class="type-label w-full border-t border-border/40 px-4 py-2 text-left text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:bg-accent/12 focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
>
|
||||
About
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class InfoPanelComponent {
|
||||
readonly body = input.required<BodyDetailViewModel>();
|
||||
|
||||
readonly readouts = computed(() => bodyReadouts(this.body()));
|
||||
readonly bookmarks = inject(BookmarksStore);
|
||||
|
||||
constructor(private readonly router: Router) {}
|
||||
private readonly articles = inject(ArticleService);
|
||||
|
||||
readonly article = signal<Article | null>(null);
|
||||
readonly aboutState = signal<'idle' | 'loading' | 'none' | 'unavailable'>('idle');
|
||||
|
||||
constructor(private readonly router: Router) {
|
||||
// The panel is reused as the route's parameter changes, so what was asked about one body
|
||||
// must not still be showing under the next one's name.
|
||||
effect(() => {
|
||||
this.body();
|
||||
this.article.set(null);
|
||||
this.aboutState.set('idle');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the article, on the press and not before. The kind goes with the name because
|
||||
* Wikipedia disambiguates by it — "Titan" alone is a list of everything called Titan.
|
||||
*/
|
||||
async loadArticle(): Promise<void> {
|
||||
this.aboutState.set('loading');
|
||||
const result = await this.articles.lookup(this.body().name, this.readouts().kindLabel.toLowerCase());
|
||||
if (result.status === 'found') {
|
||||
this.article.set(result.article);
|
||||
this.aboutState.set('idle');
|
||||
return;
|
||||
}
|
||||
this.article.set(null);
|
||||
this.aboutState.set(result.status);
|
||||
}
|
||||
|
||||
readonly readouts = computed(() => bodyReadouts(this.body()));
|
||||
|
||||
goBack(): void {
|
||||
void this.router.navigate(['/']);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* How a size in pixels becomes a size the scene can draw.
|
||||
*
|
||||
* Sprites in this view are sized angularly rather than in world units, so a star holds the same
|
||||
* share of the screen however far away it is and whatever the window is doing. Pixels are what
|
||||
* the figures are chosen in, though — "a star is between one and a half and six pixels across"
|
||||
* is a statement someone can check by looking — so the two are related through a reference
|
||||
* viewport and field of view, and the pixel figures are exact only at that height.
|
||||
*
|
||||
* Shared rather than restated per renderer: the star field and the rings drawn over it have to
|
||||
* agree, or a ring sits a little wide of the star it belongs to at some window sizes and not at
|
||||
* others.
|
||||
*/
|
||||
|
||||
export const REFERENCE_VIEWPORT_HEIGHT_PX = 900;
|
||||
export const REFERENCE_FOV_DEGREES = 55;
|
||||
|
||||
/** Multiply a size in reference pixels by this to get the angular size the material wants. */
|
||||
export const PIXELS_TO_ANGULAR_SIZE = (2 * Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 180 / 2)) / REFERENCE_VIEWPORT_HEIGHT_PX;
|
||||
@@ -1,4 +1,6 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
|
||||
import { SceneCamera } from '../../core/engine/engine.service';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
export interface CameraPose {
|
||||
@@ -27,7 +29,7 @@ export class CameraRigController {
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly camera: THREE.PerspectiveCamera,
|
||||
private readonly camera: SceneCamera,
|
||||
private readonly controls: OrbitControls
|
||||
) {}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { color, float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
|
||||
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES } from './angular-size';
|
||||
|
||||
/** Ring diameter in screen pixels at the reference viewport — angular, like the star points. */
|
||||
const RING_SIZE_PX = 12;
|
||||
const RING_PEAK_OPACITY = 0.35;
|
||||
/** Ring radius and stroke half-width in quad-uv units (the quad runs 0..1, centre 0.5). */
|
||||
const RING_RADIUS_UV = 0.42;
|
||||
const RING_STROKE_UV = 0.06;
|
||||
|
||||
/** A unit quad centred on the origin — the billboard every ring instance is drawn on. */
|
||||
function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometry {
|
||||
const geometry = new THREE.InstancedBufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]), 3));
|
||||
geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), 2));
|
||||
geometry.setIndex([0, 1, 2, 0, 2, 3]);
|
||||
geometry.instanceCount = instanceCount;
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/**
|
||||
* A faint accent ring around every star known to host planets: the one binary fact about a
|
||||
* point of light worth reading at a glance from the neighbourhood view, since it is the one
|
||||
* thing that says "there is somewhere to go here".
|
||||
*
|
||||
* Drawn the way the star field draws its stars — instanced unattenuated sprites — so the rings
|
||||
* sit exactly on the field's own points at any zoom and window size. The ring itself is a band
|
||||
* of the quad's uv distance from centre, not a texture, so it stays a hairline at any scale.
|
||||
*/
|
||||
export class HostStarRings {
|
||||
readonly object: THREE.Mesh;
|
||||
readonly count: number;
|
||||
|
||||
private readonly geometry: THREE.InstancedBufferGeometry;
|
||||
private readonly material: THREE.SpriteNodeMaterial;
|
||||
private readonly opacity = uniform(RING_PEAK_OPACITY);
|
||||
/** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */
|
||||
private readonly perspective = uniform(1);
|
||||
private readonly orthographicScale = uniform(float(0));
|
||||
|
||||
constructor(hosts: readonly StarRecord[], accent: number) {
|
||||
const positions = new Float32Array(hosts.length * 3);
|
||||
hosts.forEach((star, i) => positions.set([star.x, star.y, star.z], i * 3));
|
||||
this.geometry = createQuadGeometry(hosts.length);
|
||||
|
||||
this.material = new THREE.SpriteNodeMaterial({ transparent: true, depthWrite: false });
|
||||
// As in the star field: the angular-to-world conversion is done here rather than by
|
||||
// `sizeAttenuation: false`, which three.js applies only under a perspective camera.
|
||||
this.material.sizeAttenuation = true;
|
||||
const position = instancedBufferAttribute<'vec3'>(new THREE.InstancedBufferAttribute(positions, 3), 'vec3');
|
||||
this.material.positionNode = position;
|
||||
const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate();
|
||||
this.material.scaleNode = float(RING_SIZE_PX * PIXELS_TO_ANGULAR_SIZE).mul(mix(this.orthographicScale, viewDepth, this.perspective));
|
||||
this.material.colorNode = color(accent);
|
||||
// Opaque on the ring's centreline, falling to nothing one stroke-width either side.
|
||||
const distanceFromRing = uv().sub(vec2(0.5)).length().sub(RING_RADIUS_UV).abs();
|
||||
this.material.opacityNode = smoothstep(RING_STROKE_UV, 0.0, distanceFromRing).mul(this.opacity);
|
||||
|
||||
this.object = new THREE.Mesh(this.geometry, this.material);
|
||||
this.object.name = 'host-star-rings';
|
||||
// As for the star field: the quad's bounds say nothing about where the instances are.
|
||||
this.object.frustumCulled = false;
|
||||
this.count = hosts.length;
|
||||
}
|
||||
|
||||
/** Which projection the rings are drawn under; see `StarFieldRenderer.setProjection`. */
|
||||
setProjection(halfHeightWorld: number | null): void {
|
||||
this.perspective.value = halfHeightWorld === null ? 1 : 0;
|
||||
this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360);
|
||||
}
|
||||
|
||||
/** Crossfaded with the local grid: from outside the Galaxy the rings are noise. */
|
||||
setStrength(strength: number): void {
|
||||
const clamped = THREE.MathUtils.clamp(strength, 0, 1);
|
||||
this.opacity.value = RING_PEAK_OPACITY * clamped;
|
||||
this.object.visible = clamped > 0;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.geometry.dispose();
|
||||
this.material.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
|
||||
/** Faint, because there are tens of thousands of them and none is worth reading on its own. */
|
||||
const LINK_OPACITY = 0.16;
|
||||
/** The one route is the figure; the graph it is drawn on is the ground. */
|
||||
const ROUTE_OPACITY = 0.9;
|
||||
/**
|
||||
* How far the graph falls back while a route is up. Near the Sun the catalogue is dense enough
|
||||
* that the links are a solid haze, and a chain drawn through it would be one bright thread in a
|
||||
* bright cloud; stepping the ground down is what makes the figure a figure.
|
||||
*/
|
||||
const GROUND_WHILE_ROUTED = 0.4;
|
||||
|
||||
export interface LinkPoint {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly z: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The jump-link graph, and one route through it.
|
||||
*
|
||||
* Both are line segments in the galaxy's parsec frame: the graph as a single buffer, because a
|
||||
* pair of vertices per link is the cheapest way to draw a hundred thousand of them, and the
|
||||
* route as a second, brighter one over the top. The route is a strip rather than a set of pairs
|
||||
* so a chain of hops reads as one continuous thing.
|
||||
*/
|
||||
export class JumpLinkRenderer {
|
||||
readonly object = new THREE.Group();
|
||||
|
||||
private readonly linkMaterial: THREE.LineBasicMaterial;
|
||||
private readonly routeMaterial: THREE.LineBasicMaterial;
|
||||
private readonly links: THREE.LineSegments;
|
||||
private readonly route: THREE.Line;
|
||||
private strength = 1;
|
||||
private routed = false;
|
||||
|
||||
constructor(accent: THREE.ColorRepresentation) {
|
||||
this.linkMaterial = new THREE.LineBasicMaterial({ color: accent, transparent: true, opacity: LINK_OPACITY, depthWrite: false });
|
||||
this.routeMaterial = new THREE.LineBasicMaterial({ color: accent, transparent: true, opacity: ROUTE_OPACITY, depthWrite: false });
|
||||
|
||||
this.links = new THREE.LineSegments(new THREE.BufferGeometry(), this.linkMaterial);
|
||||
this.route = new THREE.Line(new THREE.BufferGeometry(), this.routeMaterial);
|
||||
// Both are rebuilt from scratch whenever they change, so their bounds are only ever right
|
||||
// by accident between rebuilds; culling on a stale sphere drops the graph mid-pan.
|
||||
this.links.frustumCulled = false;
|
||||
this.route.frustumCulled = false;
|
||||
this.object.add(this.links, this.route);
|
||||
this.setSegments(new Float32Array(0));
|
||||
this.setRoute([], () => undefined);
|
||||
}
|
||||
|
||||
/** The graph, as vertex pairs: six floats a link, one end then the other. See `jumpLinkSegments`. */
|
||||
setSegments(vertices: Float32Array): void {
|
||||
this.replaceGeometry(this.links, vertices);
|
||||
// Given rather than left for the renderer to compute: it wants a bounding sphere to sort by and,
|
||||
// finding none, walks every vertex on the main thread in the first frame that draws the graph.
|
||||
// The graph is never culled, and it sorts by its centre, where the catalogue is centred too.
|
||||
this.links.geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), Infinity);
|
||||
}
|
||||
|
||||
/** The chain to draw over the graph, departure first. Fewer than two stars draws nothing. */
|
||||
setRoute(starIds: readonly number[], positionOf: (starId: number) => LinkPoint | undefined): void {
|
||||
const points = starIds.map(positionOf).filter((point): point is LinkPoint => point !== undefined);
|
||||
this.routed = points.length >= 2;
|
||||
this.applyOpacity();
|
||||
const vertices = new Float32Array(points.length < 2 ? 0 : points.length * 3);
|
||||
points.forEach((point, i) => {
|
||||
if (vertices.length > 0) {
|
||||
vertices.set([point.x, point.y, point.z], i * 3);
|
||||
}
|
||||
});
|
||||
this.replaceGeometry(this.route, vertices);
|
||||
}
|
||||
|
||||
/** Crossfaded with the local layer: from outside the Galaxy the graph is a smear. */
|
||||
setStrength(strength: number): void {
|
||||
this.strength = THREE.MathUtils.clamp(strength, 0, 1);
|
||||
this.applyOpacity();
|
||||
this.object.visible = this.strength > 0;
|
||||
}
|
||||
|
||||
private applyOpacity(): void {
|
||||
this.linkMaterial.opacity = LINK_OPACITY * this.strength * (this.routed ? GROUND_WHILE_ROUTED : 1);
|
||||
this.routeMaterial.opacity = ROUTE_OPACITY * this.strength;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.links.geometry.dispose();
|
||||
this.route.geometry.dispose();
|
||||
this.linkMaterial.dispose();
|
||||
this.routeMaterial.dispose();
|
||||
}
|
||||
|
||||
private replaceGeometry(target: THREE.LineSegments | THREE.Line, vertices: Float32Array): void {
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
|
||||
const previous = target.geometry;
|
||||
target.geometry = geometry;
|
||||
previous.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ReservedBox, ringPlacement } from './label-ring';
|
||||
|
||||
const VIEWPORT = { width: 1440, height: 900 };
|
||||
|
||||
/** Where a placement lands on screen, which is what the rule is really about. */
|
||||
function screen(placement: { x: number; y: number }): { x: number; y: number } {
|
||||
return { x: Math.round(((placement.x + 1) / 2) * VIEWPORT.width), y: Math.round(((1 - placement.y) / 2) * VIEWPORT.height) };
|
||||
}
|
||||
|
||||
describe('ringPlacement', () => {
|
||||
it('puts a name where its bearing points, on a ring that is round on screen', () => {
|
||||
const right = ringPlacement(0, 0.74, VIEWPORT);
|
||||
const up = ringPlacement(Math.PI / 2, 0.74, VIEWPORT);
|
||||
|
||||
// Same distance from the centre in pixels, despite the frame being wider than it is tall.
|
||||
const centre = { x: VIEWPORT.width / 2, y: VIEWPORT.height / 2 };
|
||||
const radius = (p: { x: number; y: number }) => Math.hypot(screen(p).x - centre.x, screen(p).y - centre.y);
|
||||
expect(radius(right!)).toBeCloseTo(radius(up!), 0);
|
||||
expect(screen(right!).y).toBe(450);
|
||||
expect(screen(up!).x).toBe(720);
|
||||
});
|
||||
|
||||
it('leaves the bearing alone when nothing is in the way', () => {
|
||||
expect(ringPlacement(1.1, 0.74, VIEWPORT)?.angle).toBeCloseTo(1.1);
|
||||
});
|
||||
|
||||
it('slides a name along the ring rather than printing it behind a panel', () => {
|
||||
// The readout panel, bottom left, where the ring passes.
|
||||
const readout: ReservedBox = { left: 24, top: 640, right: 536, bottom: 830 };
|
||||
const behindIt = (5 * Math.PI) / 4;
|
||||
|
||||
const placed = ringPlacement(behindIt, 0.74, VIEWPORT, [readout]);
|
||||
|
||||
expect(placed).not.toBeNull();
|
||||
expect(placed!.angle).not.toBeCloseTo(behindIt);
|
||||
const { x, y } = screen(placed!);
|
||||
expect(x > readout.right || x < readout.left || y < readout.top || y > readout.bottom).toBe(true);
|
||||
});
|
||||
|
||||
it('moves it the smallest distance that clears, and to the nearer side', () => {
|
||||
const box: ReservedBox = { left: 0, top: 0, right: 1440, bottom: 200 };
|
||||
const straightUp = Math.PI / 2;
|
||||
|
||||
const placed = ringPlacement(straightUp, 0.74, VIEWPORT, [box]);
|
||||
|
||||
expect(placed).not.toBeNull();
|
||||
expect(Math.abs(placed!.angle - straightUp)).toBeLessThanOrEqual(Math.PI / 3);
|
||||
});
|
||||
|
||||
it('gives up rather than half-hide a name, when everything near its bearing is covered', () => {
|
||||
const wall: ReservedBox = { left: 0, top: 0, right: 1440, bottom: 900 };
|
||||
|
||||
expect(ringPlacement(0, 0.74, VIEWPORT, [wall])).toBeNull();
|
||||
});
|
||||
|
||||
it('counts the width of the text, not just the point it hangs from', () => {
|
||||
// A panel the anchor clears by 40px but the text does not.
|
||||
const justRight: ReservedBox = { left: 1150, top: 400, right: 1440, bottom: 500 };
|
||||
|
||||
const placed = ringPlacement(0, 0.74, VIEWPORT, [justRight]);
|
||||
|
||||
expect(placed!.angle).not.toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('fits a frame held upright, where sizing against the height alone would miss it entirely', () => {
|
||||
const phone = { width: 390, height: 844 };
|
||||
|
||||
const right = ringPlacement(0, 0.74, phone);
|
||||
const up = ringPlacement(Math.PI / 2, 0.74, phone);
|
||||
|
||||
expect(Math.abs(right!.x)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(up!.y)).toBeLessThanOrEqual(1);
|
||||
// Still a circle: the same number of pixels out, whichever way it is measured.
|
||||
expect(Math.abs(right!.x) * (phone.width / 2)).toBeCloseTo(Math.abs(up!.y) * (phone.height / 2), 0);
|
||||
});
|
||||
|
||||
it('survives a viewport with no height rather than dividing by it', () => {
|
||||
expect(ringPlacement(0, 0.74, { width: 0, height: 0 })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Where a name goes on the ring around the view, given the direction it stands for and the
|
||||
* panels already occupying the frame.
|
||||
*
|
||||
* Pure and in screen space, so the rule can be read and tested without a scene: the caller turns
|
||||
* a direction into an angle, this decides where on the ring that angle can actually be printed,
|
||||
* and the caller turns the answer back into a point the renderer can project.
|
||||
*/
|
||||
|
||||
/** A box the ring must not print into, in pixels from the top-left of the viewport. */
|
||||
export interface ReservedBox {
|
||||
readonly left: number;
|
||||
readonly top: number;
|
||||
readonly right: number;
|
||||
readonly bottom: number;
|
||||
}
|
||||
|
||||
export interface RingViewport {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
/** A place on the ring, in normalised device coordinates (-1..1, y up). */
|
||||
export interface RingPlacement {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
/** The angle actually used, which is the requested one unless a panel was in the way. */
|
||||
readonly angle: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the bearing may be bent to get out from behind a panel, and in what steps. Bending is
|
||||
* a lie about the direction, so it is kept small and always tried in the smallest amount that
|
||||
* works, alternating sides so the name ends up on whichever side of the panel is nearer.
|
||||
*/
|
||||
const MAX_NUDGE_RADIANS = Math.PI / 3;
|
||||
const NUDGE_STEP_RADIANS = Math.PI / 24;
|
||||
|
||||
/**
|
||||
* The label's text runs this far from its anchor, as a fraction of the viewport width, and this
|
||||
* tall. A name clears a panel only if the whole line does, not just the point it hangs from.
|
||||
*/
|
||||
const LABEL_REACH_FRACTION = 0.13;
|
||||
const LABEL_HEIGHT_PX = 30;
|
||||
|
||||
function overlaps(x: number, y: number, viewport: RingViewport, reserved: readonly ReservedBox[]): boolean {
|
||||
const px = ((x + 1) / 2) * viewport.width;
|
||||
const py = ((1 - y) / 2) * viewport.height;
|
||||
const reach = viewport.width * LABEL_REACH_FRACTION;
|
||||
// Either side, because which side the text hangs on is decided later, by the label pass.
|
||||
const left = px - reach;
|
||||
const right = px + reach;
|
||||
const top = py - LABEL_HEIGHT_PX / 2;
|
||||
const bottom = py + LABEL_HEIGHT_PX / 2;
|
||||
return reserved.some((box) => left < box.right && right > box.left && top < box.bottom && bottom > box.top);
|
||||
}
|
||||
|
||||
/**
|
||||
* Places one name on the ring at `angle`, moved along the ring if a panel is in the way, or
|
||||
* `null` if the whole neighbourhood of that angle is covered — better absent than half hidden
|
||||
* behind a readout.
|
||||
*
|
||||
* `radius` is a fraction of the frame's shorter side, so the ring is a circle on screen — and
|
||||
* fits whichever way up the frame is. Sizing it against the height alone puts the ring a
|
||||
* viewport and a half wide on a phone held upright, which is to say off both edges.
|
||||
*/
|
||||
export function ringPlacement(
|
||||
angle: number,
|
||||
radius: number,
|
||||
viewport: RingViewport,
|
||||
reserved: readonly ReservedBox[] = []
|
||||
): RingPlacement | null {
|
||||
const shorterSide = Math.min(viewport.width, viewport.height);
|
||||
const scaleX = viewport.width === 0 ? radius : (radius * shorterSide) / viewport.width;
|
||||
const scaleY = viewport.height === 0 ? radius : (radius * shorterSide) / viewport.height;
|
||||
for (let nudge = 0; nudge <= MAX_NUDGE_RADIANS; nudge += NUDGE_STEP_RADIANS) {
|
||||
for (const candidate of nudge === 0 ? [angle] : [angle + nudge, angle - nudge]) {
|
||||
const x = Math.cos(candidate) * scaleX;
|
||||
const y = Math.sin(candidate) * scaleY;
|
||||
if (!overlaps(x, y, viewport, reserved)) {
|
||||
return { x, y, angle: candidate };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { jumpLinkSegments, routeBetween } from '../../shared/astro/jump-links';
|
||||
import { RoutingRequest, RoutingResponse } from '../../shared/astro/routing';
|
||||
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { RoutingClient, SupersededRequest } from './routing-client';
|
||||
|
||||
const STARS: StarRecord[] = Array.from({ length: 6 }, (_, i) => ({
|
||||
id: 100 + i,
|
||||
name: `star-${i}`,
|
||||
x: i < 5 ? i : 9,
|
||||
y: 0,
|
||||
z: 0,
|
||||
magnitude: 5,
|
||||
spectralType: 'G2V',
|
||||
colorIndex: 0.6
|
||||
}));
|
||||
const POSITIONS = Float32Array.from(STARS.flatMap((star) => [star.x, star.y, star.z]));
|
||||
const index = new StarNeighbourhood(STARS);
|
||||
/** Every star drawn. */
|
||||
const ALL = Uint32Array.from(STARS.keys());
|
||||
|
||||
/** Flushes settled promises and their handlers. */
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** A worker that records what it is sent and answers only when told to. */
|
||||
class FakeWorker {
|
||||
readonly sent: Array<RoutingRequest | { kind: 'catalogue' }> = [];
|
||||
readonly transferred: ArrayBufferLike[] = [];
|
||||
private readonly listeners: Record<string, Array<(event: { data?: unknown }) => void>> = {};
|
||||
terminated = false;
|
||||
|
||||
postMessage(message: RoutingRequest | { kind: 'catalogue' }, transfer: Transferable[] = []): void {
|
||||
this.sent.push(message);
|
||||
this.transferred.push(...(transfer as ArrayBufferLike[]));
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: (event: { data?: unknown }) => void): void {
|
||||
(this.listeners[type] ??= []).push(listener);
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated = true;
|
||||
}
|
||||
|
||||
/** The requests sent so far, catalogue aside. */
|
||||
get requests(): RoutingRequest[] {
|
||||
return this.sent.filter((message): message is RoutingRequest => message.kind !== 'catalogue');
|
||||
}
|
||||
|
||||
answer(response: RoutingResponse): void {
|
||||
for (const listener of this.listeners['message'] ?? []) listener({ data: response });
|
||||
}
|
||||
|
||||
fail(): void {
|
||||
for (const listener of this.listeners['error'] ?? []) listener({});
|
||||
}
|
||||
}
|
||||
|
||||
function clientWithFake(): { client: RoutingClient; worker: FakeWorker } {
|
||||
const worker = new FakeWorker();
|
||||
const client = new RoutingClient(STARS, POSITIONS, index, () => worker as unknown as Worker);
|
||||
return { client, worker };
|
||||
}
|
||||
|
||||
// The unit tests' DOM has no Worker, which is exactly the case the client answers in place.
|
||||
describe('RoutingClient without a worker', () => {
|
||||
it('has no Worker to use here, so the in-place answers are what is being tested', () => {
|
||||
expect(typeof Worker).toBe('undefined');
|
||||
});
|
||||
|
||||
it('answers a route from the index it was given', async () => {
|
||||
const client = new RoutingClient(STARS, POSITIONS, index);
|
||||
|
||||
await expect(client.route(100, 104, 1.5, 8)).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5).route, neededRangePc: null, gaveUp: false, least: true });
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
it('answers a refused route with the range that would open it', async () => {
|
||||
const client = new RoutingClient(STARS, POSITIONS, index);
|
||||
|
||||
const answer = await client.route(100, 105, 1.5, 8);
|
||||
|
||||
expect(answer.route).toBeNull();
|
||||
expect(answer.neededRangePc).toBeCloseTo(5, 1);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
it('answers the graph as segments', async () => {
|
||||
const client = new RoutingClient(STARS, POSITIONS, index);
|
||||
|
||||
expect(Array.from(await client.links(1.5, ALL))).toEqual(Array.from(jumpLinkSegments(index, 1.5)));
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
it('keeps only the links its budget holds, nearest the centre first', async () => {
|
||||
const client = new RoutingClient(STARS, POSITIONS, index);
|
||||
|
||||
// Stars at x = 0 to 4 a parsec apart: from a centre at 3.9, one and a half parsecs is the link 3-4 alone.
|
||||
const segments = await client.links(1.5, ALL, { centre: { x: 3.9, y: 0, z: 0 }, lengthPc: 1.5 });
|
||||
|
||||
expect(Array.from(segments)).toEqual([3, 0, 0, 4, 0, 0]);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
it('links only the stars it is told are drawn', async () => {
|
||||
const client = new RoutingClient(STARS, POSITIONS, index);
|
||||
|
||||
// Stars at x = 0, 1 and 3: only the first two are within 1.5 pc of each other.
|
||||
expect(Array.from(await client.links(1.5, Uint32Array.of(0, 1, 3)))).toEqual([0, 0, 0, 1, 0, 0]);
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RoutingClient with a worker', () => {
|
||||
it('sends the catalogue first, then one request at a time', () => {
|
||||
const { client, worker } = clientWithFake();
|
||||
|
||||
void client.links(8, ALL);
|
||||
void client.links(3, ALL);
|
||||
|
||||
expect(worker.sent[0].kind).toBe('catalogue');
|
||||
expect(worker.requests).toHaveLength(1);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
// A graph at 8 pc is seconds of work the worker cannot drop once started. Every pause on the
|
||||
// range slider used to queue another, and a route asked for after them waited behind them all.
|
||||
it('replaces a waiting graph with the newer one before it is ever built, and sends a route ahead of it', async () => {
|
||||
const { client, worker } = clientWithFake();
|
||||
const first = client.links(5, ALL);
|
||||
const superseded = client.links(6, ALL).catch((error: unknown) => error);
|
||||
const latest = client.links(8, ALL);
|
||||
const route = client.route(100, 104, 1.5, 8);
|
||||
|
||||
const building = worker.requests[0];
|
||||
worker.answer({ kind: 'links', requestId: building.requestId, segments: new Float32Array(6) });
|
||||
await flush();
|
||||
|
||||
expect(await superseded).toBeInstanceOf(SupersededRequest);
|
||||
expect(worker.requests.map((request) => request.kind)).toEqual(['links', 'route']);
|
||||
await expect(first).resolves.toHaveLength(6);
|
||||
|
||||
const routeRequest = worker.requests[1];
|
||||
worker.answer({ kind: 'route', requestId: routeRequest.requestId, route: null, neededRangePc: 4, gaveUp: false, least: true });
|
||||
await expect(route).resolves.toEqual({ route: null, neededRangePc: 4, gaveUp: false, least: true });
|
||||
await flush();
|
||||
|
||||
expect(worker.requests.map((request) => (request.kind === 'links' ? request.rangePc : request.kind))).toEqual([5, 'route', 8]);
|
||||
const lastGraph = worker.requests[2];
|
||||
worker.answer({ kind: 'links', requestId: lastGraph.requestId, segments: new Float32Array(12) });
|
||||
await expect(latest).resolves.toHaveLength(12);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
it('shares the answer to a route already on its way rather than asking it twice', async () => {
|
||||
const { client, worker } = clientWithFake();
|
||||
const once = client.route(100, 104, 1.5, 8);
|
||||
const again = client.route(100, 104, 1.5, 8);
|
||||
const widerRange = client.route(100, 104, 2.5, 8);
|
||||
|
||||
expect(worker.requests).toHaveLength(1);
|
||||
worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: 4, gaveUp: false, least: true });
|
||||
|
||||
expect(await again).toEqual(await once);
|
||||
await flush();
|
||||
// The same two stars at another range is another question.
|
||||
expect(worker.requests.map((request) => request.rangePc)).toEqual([1.5, 2.5]);
|
||||
worker.answer({ kind: 'route', requestId: worker.requests[1].requestId, route: null, neededRangePc: null, gaveUp: false, least: true });
|
||||
await expect(widerRange).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: false, least: true });
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
// Turning the layer off and on again while the worker is busy asks for the same graph twice. Were
|
||||
// the second to replace the first, the first's rejection would wipe the scene's record of the second.
|
||||
it('shares a graph already on its way for the same range and the same list of drawn stars', async () => {
|
||||
const { client, worker } = clientWithFake();
|
||||
const drawn = Uint32Array.of(0, 1, 2);
|
||||
const building = client.links(3, drawn);
|
||||
const waiting = client.links(5, drawn);
|
||||
const again = client.links(5, drawn);
|
||||
const sameAsBuilding = client.links(3, drawn);
|
||||
|
||||
worker.answer({ kind: 'links', requestId: worker.requests[0].requestId, segments: new Float32Array(6) });
|
||||
await expect(building).resolves.toHaveLength(6);
|
||||
await expect(sameAsBuilding).resolves.toHaveLength(6);
|
||||
await flush();
|
||||
worker.answer({ kind: 'links', requestId: worker.requests[1].requestId, segments: new Float32Array(12) });
|
||||
await expect(waiting).resolves.toHaveLength(12);
|
||||
await expect(again).resolves.toHaveLength(12);
|
||||
expect(worker.requests.map((request) => request.kind === 'links' && request.rangePc)).toEqual([3, 5]);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
it('builds a graph for each set of drawn stars asked about, and never gives the list away', async () => {
|
||||
const { client, worker } = clientWithFake();
|
||||
const near = Uint32Array.of(0, 1, 2);
|
||||
const far = Uint32Array.of(3, 4, 5);
|
||||
void client.links(3, near);
|
||||
const second = client.links(3, far);
|
||||
|
||||
worker.answer({ kind: 'links', requestId: worker.requests[0].requestId, segments: new Float32Array(6) });
|
||||
await flush();
|
||||
|
||||
expect(worker.requests.map((request) => request.kind === 'links' && Array.from(request.drawn))).toEqual([[0, 1, 2], [3, 4, 5]]);
|
||||
worker.answer({ kind: 'links', requestId: worker.requests[1].requestId, segments: new Float32Array(12) });
|
||||
await expect(second).resolves.toHaveLength(12);
|
||||
// The same list at a different budget is a different graph.
|
||||
void client.links(3, far, { centre: { x: 1, y: 0, z: 0 }, lengthPc: 10 });
|
||||
void client.links(3, far, { centre: { x: 1, y: 0, z: 0 }, lengthPc: 20 });
|
||||
worker.answer({ kind: 'links', requestId: worker.requests[2].requestId, segments: new Float32Array(0) });
|
||||
await flush();
|
||||
expect(worker.requests.map((request) => request.kind === 'links' && request.budget?.lengthPc)).toEqual([undefined, undefined, 10, 20]);
|
||||
// The star field goes on drawing and picking from these lists, so they are copied, not moved.
|
||||
expect(worker.transferred).not.toContain(near.buffer);
|
||||
expect(worker.transferred).not.toContain(far.buffer);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
it('hands on that the search gave up, along with the answer it did give', async () => {
|
||||
const { client, worker } = clientWithFake();
|
||||
const answer = client.route(100, 105, 1.5, 8);
|
||||
|
||||
worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: null, gaveUp: true, least: false });
|
||||
|
||||
await expect(answer).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: true, least: false });
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
it('rejects a request the worker failed on, and goes on to the next', async () => {
|
||||
const { client, worker } = clientWithFake();
|
||||
const failing = client.route(100, 104, 1.5, 8).catch((error: unknown) => error);
|
||||
const next = client.links(3, ALL);
|
||||
|
||||
worker.answer({ kind: 'failed', requestId: worker.requests[0].requestId, message: 'out of memory' });
|
||||
|
||||
expect(((await failing) as Error).message).toBe('out of memory');
|
||||
await flush();
|
||||
expect(worker.requests.map((request) => request.kind)).toEqual(['route', 'links']);
|
||||
worker.answer({ kind: 'links', requestId: worker.requests[1].requestId, segments: new Float32Array(0) });
|
||||
await expect(next).resolves.toHaveLength(0);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
it('answers in place what a worker that failed to load left outstanding, and everything after', async () => {
|
||||
const { client, worker } = clientWithFake();
|
||||
const route = client.route(100, 104, 1.5, 8);
|
||||
const graph = client.links(1.5, Uint32Array.of(0, 1, 3));
|
||||
|
||||
worker.fail();
|
||||
|
||||
await expect(route).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5).route, neededRangePc: null, gaveUp: false, least: true });
|
||||
expect(Array.from(await graph)).toEqual([0, 0, 0, 1, 0, 0]);
|
||||
await expect(client.route(100, 105, 1.5, 8)).resolves.toMatchObject({ route: null });
|
||||
expect(worker.terminated).toBe(true);
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { answerRouting, RoutingRequest, RoutingResponse } from '../../shared/astro/routing';
|
||||
import { LinkBudget, Route } from '../../shared/astro/jump-links';
|
||||
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
|
||||
export interface RouteAnswer {
|
||||
readonly route: Route | null;
|
||||
readonly neededRangePc: number | null;
|
||||
/** True when the search at the range asked for gave up rather than ruling a route out. */
|
||||
readonly gaveUp: boolean;
|
||||
/** True when the search for a range that would work looked everywhere up to the ceiling. */
|
||||
readonly least: boolean;
|
||||
}
|
||||
|
||||
/** A request dropped before it was sent, because a newer one of the same kind replaced it. */
|
||||
export class SupersededRequest extends Error {
|
||||
constructor() {
|
||||
super('Superseded by a newer request');
|
||||
}
|
||||
}
|
||||
|
||||
/** A request made and not yet answered: what was asked, and the promise whoever asked is holding. */
|
||||
interface Outstanding {
|
||||
readonly request: RoutingRequest;
|
||||
readonly promise: Promise<RoutingResponse>;
|
||||
readonly resolve: (response: RoutingResponse) => void;
|
||||
readonly reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
function outstanding(request: RoutingRequest): Outstanding {
|
||||
let resolve!: (response: RoutingResponse) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<RoutingResponse>((onResolve, onReject) => {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
});
|
||||
return { request, promise, resolve, reject };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two requests ask the same question. A graph is the same when it is for the same range, the
|
||||
* same budget and the very same list of drawn stars: the star field replaces that list whenever the
|
||||
* set changes, so one array is one set, and comparing 70 000 indices would cost more than sharing
|
||||
* could save.
|
||||
*/
|
||||
function asksTheSame(a: RoutingRequest, b: RoutingRequest): boolean {
|
||||
if (a.kind === 'links' || b.kind === 'links') {
|
||||
return (
|
||||
a.kind === 'links' &&
|
||||
b.kind === 'links' &&
|
||||
a.rangePc === b.rangePc &&
|
||||
a.drawn === b.drawn &&
|
||||
a.budget?.lengthPc === b.budget?.lengthPc &&
|
||||
a.budget?.centre.x === b.budget?.centre.x &&
|
||||
a.budget?.centre.y === b.budget?.centre.y &&
|
||||
a.budget?.centre.z === b.budget?.centre.z
|
||||
);
|
||||
}
|
||||
return a.fromId === b.fromId && a.toId === b.toId && a.rangePc === b.rangePc && a.ceilingPc === b.ceilingPc;
|
||||
}
|
||||
|
||||
/** The routing worker, where this environment has one. */
|
||||
function startRoutingWorker(): Worker | undefined {
|
||||
return typeof Worker === 'undefined' ? undefined : new Worker(new URL('../../shared/astro/routing.worker', import.meta.url), { type: 'module' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the route questions of a worker holding its own copy of the catalogue, and hands back
|
||||
* promises.
|
||||
*
|
||||
* The worker answers one request at a time and cannot drop one it has started: a route with no path
|
||||
* can be seconds of work, and a graph of the drawn stars at 8 pc a few hundred milliseconds. So
|
||||
* requests are held here and sent one by one, and while one is out, only the latest of each kind
|
||||
* waits behind it — a newer graph replaces an older one before it is ever built, and the older
|
||||
* promise is rejected with {@link SupersededRequest}. Routes go ahead of graphs, being quick to ask
|
||||
* for and asked for by a click. The same question asked again while it is still outstanding shares
|
||||
* the answer rather than being worked out twice; see `asksTheSame`.
|
||||
*
|
||||
* Where there is no worker — the unit tests' DOM has none, and a worker can fail to load or crash —
|
||||
* the same answers are worked out in place, from the index the scene already holds.
|
||||
*/
|
||||
export class RoutingClient {
|
||||
private worker?: Worker;
|
||||
private inFlight?: Outstanding;
|
||||
private readonly waiting: Partial<Record<RoutingRequest['kind'], Outstanding>> = {};
|
||||
private nextRequestId = 0;
|
||||
|
||||
constructor(
|
||||
stars: readonly StarRecord[],
|
||||
positions: Float32Array,
|
||||
private readonly localIndex: StarNeighbourhood,
|
||||
startWorker: () => Worker | undefined = startRoutingWorker
|
||||
) {
|
||||
this.worker = startWorker();
|
||||
if (!this.worker) {
|
||||
return;
|
||||
}
|
||||
this.worker.addEventListener('message', ({ data }: MessageEvent<RoutingResponse>) => this.settle(data));
|
||||
// A worker that fails to load, or dies, answers nothing further: everything outstanding, and
|
||||
// everything asked from here on, is worked out in place instead of waiting for good.
|
||||
this.worker.addEventListener('error', () => this.abandonWorker());
|
||||
this.worker.addEventListener('messageerror', () => this.abandonWorker());
|
||||
// Copies, since the scene goes on using its own; transferred, so the copy is sent and not cloned again.
|
||||
const ids = Int32Array.from(stars, (star) => star.id);
|
||||
const copy = positions.slice();
|
||||
this.worker.postMessage({ kind: 'catalogue', ids, positions: copy }, [ids.buffer, copy.buffer]);
|
||||
}
|
||||
|
||||
route(fromId: number, toId: number, rangePc: number, ceilingPc: number): Promise<RouteAnswer> {
|
||||
return this.ask({ kind: 'route', requestId: this.nextRequestId++, fromId, toId, rangePc, ceilingPc }).then((response) =>
|
||||
response.kind === 'route'
|
||||
? { route: response.route, neededRangePc: response.neededRangePc, gaveUp: response.gaveUp, least: response.least }
|
||||
: { route: null, neededRangePc: null, gaveUp: false, least: false }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertex pairs for every link within `rangePc` between two of the `drawn` stars (catalogue
|
||||
* indices), three floats to an end; only those nearest the budget's centre that fit it, if given.
|
||||
*/
|
||||
links(rangePc: number, drawn: Uint32Array, budget?: LinkBudget): Promise<Float32Array> {
|
||||
return this.ask({ kind: 'links', requestId: this.nextRequestId++, rangePc, drawn, budget }).then((response) =>
|
||||
response.kind === 'links' ? response.segments : new Float32Array(0)
|
||||
);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.worker?.terminate();
|
||||
this.worker = undefined;
|
||||
this.inFlight = undefined;
|
||||
delete this.waiting.route;
|
||||
delete this.waiting.links;
|
||||
}
|
||||
|
||||
private ask(request: RoutingRequest): Promise<RoutingResponse> {
|
||||
if (!this.worker) {
|
||||
return new Promise((resolve) => resolve(answerRouting(this.localIndex, request)));
|
||||
}
|
||||
// Shared rather than replaced: an identical request superseding the one it repeats would reject it,
|
||||
// and whoever holds that promise would take the rejection for its own question.
|
||||
const same = [this.inFlight, this.waiting[request.kind]].find((other) => other !== undefined && asksTheSame(other.request, request));
|
||||
if (same) {
|
||||
return same.promise;
|
||||
}
|
||||
const asked = outstanding(request);
|
||||
this.waiting[request.kind]?.reject(new SupersededRequest());
|
||||
this.waiting[request.kind] = asked;
|
||||
this.sendNext();
|
||||
return asked.promise;
|
||||
}
|
||||
|
||||
private sendNext(): void {
|
||||
if (this.inFlight || !this.worker) {
|
||||
return;
|
||||
}
|
||||
const next = this.waiting.route ?? this.waiting.links;
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
delete this.waiting[next.request.kind];
|
||||
this.inFlight = next;
|
||||
// Cloned, never transferred: a graph's `drawn` is the star field's own list, still drawn and
|
||||
// picked from, and answered in place from should the worker die.
|
||||
this.worker.postMessage(next.request);
|
||||
}
|
||||
|
||||
private settle(response: RoutingResponse): void {
|
||||
const answered = this.inFlight;
|
||||
if (!answered || answered.request.requestId !== response.requestId) {
|
||||
return;
|
||||
}
|
||||
this.inFlight = undefined;
|
||||
if (response.kind === 'failed') {
|
||||
answered.reject(new Error(response.message));
|
||||
} else {
|
||||
answered.resolve(response);
|
||||
}
|
||||
this.sendNext();
|
||||
}
|
||||
|
||||
private abandonWorker(): void {
|
||||
this.worker?.terminate();
|
||||
this.worker = undefined;
|
||||
const stranded = [this.inFlight, this.waiting.route, this.waiting.links];
|
||||
this.inFlight = undefined;
|
||||
delete this.waiting.route;
|
||||
delete this.waiting.links;
|
||||
for (const request of stranded) {
|
||||
if (!request) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
request.resolve(answerRouting(this.localIndex, request.request));
|
||||
} catch (error) {
|
||||
request.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { blackbodyColor, effectiveTemperatureK } from '../../shared/astro/stellar';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
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', () => {
|
||||
// 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 explicit = colorIndexToRgb(1.105);
|
||||
const explicit = colorIndexToRgb(1.15);
|
||||
|
||||
expect(derived.r).toBeCloseTo(explicit.r, 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', () => {
|
||||
const measured = colorIndexToRgb(-0.3, 'M5');
|
||||
expect(measured.b).toBeGreaterThan(measured.r);
|
||||
@@ -152,13 +183,13 @@ describe('StarFieldRenderer', () => {
|
||||
it('finds the star under the pointer', () => {
|
||||
const renderer = new StarFieldRenderer(picked, packPositions(picked));
|
||||
// Both Near and Far project to the screen centre; either is a correct hit.
|
||||
expect([1, 2]).toContain(renderer.pickAt(new THREE.Vector2(0, 0), camera));
|
||||
expect([1, 2]).toContain(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect));
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('returns undefined when the pointer is on empty sky', () => {
|
||||
const renderer = new StarFieldRenderer(picked, packPositions(picked));
|
||||
expect(renderer.pickAt(new THREE.Vector2(-0.9, 0.9), camera)).toBeUndefined();
|
||||
expect(renderer.pickAt(new THREE.Vector2(-0.9, 0.9), camera, camera.aspect)).toBeUndefined();
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
@@ -168,10 +199,29 @@ describe('StarFieldRenderer', () => {
|
||||
const behind = [star({ id: 7, x: 0, y: 0, z: 10 })];
|
||||
const renderer = new StarFieldRenderer(behind, packPositions(behind));
|
||||
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined();
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
|
||||
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', () => {
|
||||
const spread = [
|
||||
star({ id: 1, x: 0, y: 0, z: -10 }),
|
||||
@@ -182,7 +232,7 @@ describe('StarFieldRenderer', () => {
|
||||
|
||||
// Aim at where star 2 projects, and confirm we get it rather than its neighbours.
|
||||
const target = new THREE.Vector3(0, 2, -10).project(camera);
|
||||
expect(renderer.pickAt(new THREE.Vector2(target.x, target.y), camera)).toBe(2);
|
||||
expect(renderer.pickAt(new THREE.Vector2(target.x, target.y), camera, camera.aspect)).toBe(2);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
@@ -195,7 +245,7 @@ describe('StarFieldRenderer', () => {
|
||||
// Walk outward from the centre until each stops being pickable.
|
||||
const reach = (renderer: StarFieldRenderer): number => {
|
||||
let offset = 0;
|
||||
while (offset < 1 && renderer.pickAt(new THREE.Vector2(0, offset), camera) !== undefined) {
|
||||
while (offset < 1 && renderer.pickAt(new THREE.Vector2(0, offset), camera, camera.aspect) !== undefined) {
|
||||
offset += 0.001;
|
||||
}
|
||||
return offset;
|
||||
@@ -212,13 +262,13 @@ describe('StarFieldRenderer', () => {
|
||||
const faint = [star({ id: 5, x: 0, y: 0, z: -10, magnitude: 15 })];
|
||||
const renderer = new StarFieldRenderer(faint, packPositions(faint));
|
||||
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0.005), camera)).toBe(5);
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0.005), camera, camera.aspect)).toBe(5);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('finds nothing in an empty field', () => {
|
||||
const renderer = new StarFieldRenderer([], new Float32Array(0));
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined();
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
|
||||
renderer.dispose();
|
||||
});
|
||||
});
|
||||
@@ -260,11 +310,6 @@ describe('selectDrawnStars', () => {
|
||||
expect(drawn).toEqual([0, 2, 3]);
|
||||
});
|
||||
|
||||
it('returns catalogue indices in order, so positions can be subset alongside', () => {
|
||||
const catalogue = Array.from({ length: 100 }, (_, i) => catalogueStar(i, 150, 100 - i));
|
||||
const drawn = Array.from(selectDrawnStars(catalogue, 10));
|
||||
expect(drawn).toEqual([...drawn].sort((a, b) => a - b));
|
||||
});
|
||||
});
|
||||
|
||||
describe('StarFieldRenderer render budget', () => {
|
||||
@@ -291,3 +336,212 @@ describe('StarFieldRenderer render budget', () => {
|
||||
renderer.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectDrawnStars around the view', () => {
|
||||
/** 200 bright stars 240 pc out, enough to spend any small budget on their own. */
|
||||
const brightFar = (from: number) => Array.from({ length: 200 }, (_, i) => catalogueStar(from + i, 240, 2));
|
||||
|
||||
it('draws a faint star near where the view is centred, however far that is from the Sun', () => {
|
||||
const faint = catalogueStar(0, 150, 12);
|
||||
const catalogue = [faint, ...brightFar(1)];
|
||||
|
||||
expect(Array.from(selectDrawnStars(catalogue, 20))).not.toContain(0);
|
||||
expect(Array.from(selectDrawnStars(catalogue, 20, { centre: { x: 150, y: 0, z: 0 } }))).toContain(0);
|
||||
});
|
||||
|
||||
it("keeps the Sun's neighbourhood drawn while the view looks elsewhere", () => {
|
||||
const catalogue = [catalogueStar(0, 1.3, 11), catalogueStar(1, 150, 13), ...brightFar(2)];
|
||||
|
||||
expect(Array.from(selectDrawnStars(catalogue, 20, { centre: { x: 150, y: 0, z: 0 } })).slice(0, 2)).toEqual([1, 0]);
|
||||
});
|
||||
|
||||
it('draws a pinned star wherever it is and however faint', () => {
|
||||
const catalogue = [catalogueStar(0, 240, 14), ...brightFar(1)];
|
||||
|
||||
expect(Array.from(selectDrawnStars(catalogue, 20))).not.toContain(0);
|
||||
expect(Array.from(selectDrawnStars(catalogue, 20, { pinned: [0] }))).toContain(0);
|
||||
});
|
||||
|
||||
it('spends a budget too small for everything on the pinned stars, then the view, then the Sun, then the brightest', () => {
|
||||
const catalogue = [catalogueStar(0, 1, 12), catalogueStar(1, 150, 13), catalogueStar(2, 240, 14), ...brightFar(3)];
|
||||
const focus = { centre: { x: 150, y: 0, z: 0 }, pinned: [2] };
|
||||
|
||||
expect(Array.from(selectDrawnStars(catalogue, 4, focus))).toEqual([2, 1, 0, 3]);
|
||||
expect(Array.from(selectDrawnStars(catalogue, 2, focus))).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it('keeps the brightest part of a neighbourhood the budget cannot hold whole', () => {
|
||||
const catalogue = [catalogueStar(0, 150, 9), catalogueStar(1, 151, 4), catalogueStar(2, 152, 11), catalogueStar(3, 153, 6), ...brightFar(4)];
|
||||
|
||||
expect(Array.from(selectDrawnStars(catalogue, 2, { centre: { x: 150, y: 0, z: 0 } }))).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('draws nothing twice when the view is centred on the Sun or pins a star already near it', () => {
|
||||
const catalogue = [catalogueStar(0, 1, 12), catalogueStar(1, 2, 13), ...brightFar(2)];
|
||||
const drawn = Array.from(selectDrawnStars(catalogue, 10, { centre: { x: 0, y: 0, z: 0 }, pinned: [0, 0, 1] }));
|
||||
|
||||
expect(new Set(drawn).size).toBe(drawn.length);
|
||||
expect(drawn).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectDrawnStars in view', () => {
|
||||
/** A star anywhere, with a given apparent magnitude. */
|
||||
const at = (id: number, x: number, y: number, z: number, magnitude: number) => star({ id, x, y, z, magnitude });
|
||||
/** What the camera shows, as the scene hands it over. */
|
||||
const viewOf = (camera: THREE.Camera) => new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
|
||||
/** Bright stars far in front of `testCamera`, spread across its frame. */
|
||||
const brightAhead = (from: number, count = 30) => Array.from({ length: count }, (_, i) => at(from + i, (i - count / 2) * 5, 0, -400, 2));
|
||||
/** Bright stars behind `testCamera`, which only a selection blind to the view would draw. */
|
||||
const brightBehind = (from: number, count = 30) => Array.from({ length: count }, (_, i) => at(from + i, (i - count / 2) * 5, 0, 400, 2));
|
||||
|
||||
it('draws only what is in view, and a pinned star wherever it is', () => {
|
||||
const ahead = Array.from({ length: 5 }, (_, i) => at(i, i * 10, 0, -240, 12));
|
||||
const pinnedBehind = at(5, 0, 0, 240, 14);
|
||||
const catalogue = [...ahead, pinnedBehind, ...brightBehind(6)];
|
||||
|
||||
const drawn = Array.from(selectDrawnStars(catalogue, 20, { pinned: [5], view: viewOf(testCamera()) }));
|
||||
|
||||
expect(drawn).toEqual([5, 0, 1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('reaches a quarter of the frame past its edges, and no further', () => {
|
||||
// At 100 pc in front of a 55° camera the frame's half-height is 52 pc: 1.2 of it is 62.5 pc, 1.3 is 67.7.
|
||||
const halfHeight = 100 * Math.tan((55 * Math.PI) / 360);
|
||||
const catalogue = [at(0, 0, 1.2 * halfHeight, -100, 12), at(1, 0, 1.3 * halfHeight, -100, 12), ...brightBehind(2)];
|
||||
|
||||
const drawn = Array.from(selectDrawnStars(catalogue, 20, { view: viewOf(testCamera()) }));
|
||||
|
||||
expect(drawn).toEqual([0]);
|
||||
});
|
||||
|
||||
it("draws the neighbourhood of the view's centre ahead of brighter stars, but only the part in view", () => {
|
||||
const camera = new THREE.PerspectiveCamera(55, 16 / 9, 0.01, 5000);
|
||||
camera.position.set(0, 0, -140);
|
||||
camera.lookAt(0, 0, -1000);
|
||||
camera.updateMatrixWorld(true);
|
||||
const memberAhead = at(0, 0, 0, -160, 14);
|
||||
const memberBehind = at(1, 0, 0, -130, 14);
|
||||
const catalogue = [memberAhead, memberBehind, ...Array.from({ length: 30 }, (_, i) => at(2 + i, (i - 15) * 5, 0, -600, 2))];
|
||||
|
||||
const drawn = Array.from(selectDrawnStars(catalogue, 20, { centre: { x: 0, y: 0, z: -150 }, view: viewOf(camera) }));
|
||||
|
||||
expect(drawn[0]).toBe(0);
|
||||
expect(drawn).not.toContain(1);
|
||||
});
|
||||
|
||||
it('draws the planet hosts in view first after the pinned stars, and not those out of view', () => {
|
||||
const hostAhead = at(0, 0, 0, -240, 14);
|
||||
const hostBehind = at(1, 0, 0, 240, 14);
|
||||
const nearSun = at(2, 0, 0, -10, 13);
|
||||
const catalogue = [hostAhead, hostBehind, nearSun, ...brightAhead(3)];
|
||||
const hosts = Uint8Array.from(catalogue, (_, index) => (index < 2 ? 1 : 0));
|
||||
|
||||
const drawn = Array.from(selectDrawnStars(catalogue, 3, { hosts, view: viewOf(testCamera()) }));
|
||||
|
||||
expect(drawn).toEqual([0, 2, 3]);
|
||||
});
|
||||
|
||||
it('frames a plan view as a box, however deep: behind the camera included', () => {
|
||||
const plan = new THREE.OrthographicCamera(-10, 10, 10, -10, -5000, 5000);
|
||||
plan.position.set(0, 0, 0);
|
||||
plan.lookAt(0, 0, -1);
|
||||
plan.updateMatrixWorld(true);
|
||||
const catalogue = [at(0, 0, 0, 50, 12), at(1, 12, 0, -50, 12), at(2, 13, 0, -50, 12), ...Array.from({ length: 30 }, (_, i) => at(3 + i, 500, i, 0, 2))];
|
||||
|
||||
const drawn = Array.from(selectDrawnStars(catalogue, 20, { view: viewOf(plan) }));
|
||||
|
||||
expect(drawn).toEqual([0, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StarFieldRenderer refocus', () => {
|
||||
const camera = testCamera();
|
||||
/** A faint star straight ahead, 150 pc out, among bright ones well off to the side. */
|
||||
const faintAhead = star({ id: 77, x: 0, y: 0, z: -150, magnitude: 13, colorIndex: 1.9 });
|
||||
const catalogue = [faintAhead, ...Array.from({ length: 50 }, (_, i) => star({ id: 100 + i, x: 60, y: i, z: -40, magnitude: 1, colorIndex: -0.3 + i * 0.04 }))];
|
||||
const positions = packPositions(catalogue);
|
||||
|
||||
it('draws and picks a faint star once the view is centred near it', () => {
|
||||
const renderer = new StarFieldRenderer(catalogue, positions, 10);
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
|
||||
|
||||
renderer.refocus({ centre: { x: 0, y: 0, z: -140 } });
|
||||
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBe(77);
|
||||
expect((renderer.object.geometry as THREE.InstancedBufferGeometry).instanceCount).toBe(renderer.drawnCount);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('draws a pinned star, and passes over an index past the end of the catalogue', () => {
|
||||
const renderer = new StarFieldRenderer(catalogue, positions, 10);
|
||||
|
||||
renderer.refocus({ pinned: [123456, 0] });
|
||||
|
||||
const drawnIds = Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i));
|
||||
expect(drawnIds).toContain(77);
|
||||
expect(renderer.drawnCount).toBe(10);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('gives each drawn star its own colour and size, wherever the refocus put it', () => {
|
||||
const renderer = new StarFieldRenderer(catalogue, positions, 10);
|
||||
renderer.refocus({ centre: { x: 0, y: 0, z: -140 }, pinned: [21] });
|
||||
const { colorAttribute, sizeAttribute } = renderer as unknown as { colorAttribute: THREE.InstancedBufferAttribute; sizeAttribute: THREE.InstancedBufferAttribute };
|
||||
|
||||
for (let instance = 0; instance < renderer.drawnCount; instance++) {
|
||||
const drawnStar = catalogue.find((candidate) => candidate.id === renderer.starIdAt(instance))!;
|
||||
const expected = colorIndexToRgb(drawnStar.colorIndex, drawnStar.spectralType, drawnStar.colorSystem);
|
||||
expect(colorAttribute.getX(instance)).toBeCloseTo(expected.r, 5);
|
||||
expect(colorAttribute.getZ(instance)).toBeCloseTo(expected.b, 5);
|
||||
expect(sizeAttribute.getX(instance)).toBeGreaterThan(0);
|
||||
}
|
||||
const faintSlot = Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i)).indexOf(77);
|
||||
const brightSlot = Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i)).indexOf(120);
|
||||
expect(sizeAttribute.getX(brightSlot)).toBeGreaterThan(sizeAttribute.getX(faintSlot));
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('leaves the buffers alone when the drawn set has not changed, and rewrites them when it has', () => {
|
||||
const renderer = new StarFieldRenderer(catalogue, positions, 10);
|
||||
const { positionAttribute } = renderer as unknown as { positionAttribute: THREE.InstancedBufferAttribute };
|
||||
const version = positionAttribute.version;
|
||||
|
||||
renderer.refocus({ centre: { x: 0, y: 0, z: 0 } });
|
||||
expect(positionAttribute.version).toBe(version);
|
||||
|
||||
renderer.refocus({ centre: { x: 0, y: 0, z: -140 } });
|
||||
expect(positionAttribute.version).toBeGreaterThan(version);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('drops a star from the drawn set, and from picking, once the view has moved away from it', () => {
|
||||
// The subtle failure this guards: buffers rewritten for a new selection while picking still
|
||||
// reads the old one would leave clickable ghosts where nothing is drawn.
|
||||
const renderer = new StarFieldRenderer(catalogue, positions, 10);
|
||||
renderer.refocus({ centre: { x: 0, y: 0, z: -140 } });
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBe(77);
|
||||
|
||||
renderer.refocus({ centre: { x: 0, y: 0, z: 0 } });
|
||||
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
|
||||
expect(Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i))).not.toContain(77);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('drops a star from the drawn set, and from picking, once the camera has turned away from it', () => {
|
||||
const renderer = new StarFieldRenderer(catalogue, positions, 10);
|
||||
const view = (from: THREE.Camera) => new THREE.Matrix4().multiplyMatrices(from.projectionMatrix, from.matrixWorldInverse);
|
||||
renderer.refocus({ centre: { x: 0, y: 0, z: -140 }, view: view(camera) });
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBe(77);
|
||||
|
||||
const turned = testCamera();
|
||||
turned.lookAt(0, 0, 1);
|
||||
turned.updateMatrixWorld(true);
|
||||
renderer.refocus({ centre: { x: 0, y: 0, z: -140 }, view: view(turned) });
|
||||
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
|
||||
expect(Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i))).not.toContain(77);
|
||||
renderer.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { instancedBufferAttribute, smoothstep, uv, vec2 } from 'three/tsl';
|
||||
import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
|
||||
|
||||
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
|
||||
import { BrightnessIndex, brightnessIndex, Positioned } from '../../shared/astro/brightest';
|
||||
import { blackbodyColor, effectiveTemperatureK } from '../../shared/astro/stellar';
|
||||
import { SceneCamera } from '../../core/engine/engine.service';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size';
|
||||
|
||||
/** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */
|
||||
const MIN_POINT_SIZE = 1.5;
|
||||
const MAX_POINT_SIZE = 6;
|
||||
|
||||
/**
|
||||
* Star size is expressed in pixels for readability, but the material works in angular size, so
|
||||
* the two are related through the scene's vertical field of view and a reference viewport.
|
||||
* Because the size is angular, a star keeps the same share of the screen at any window size —
|
||||
* these pixel figures are exact only at this reference height.
|
||||
*/
|
||||
const REFERENCE_VIEWPORT_HEIGHT_PX = 900;
|
||||
const REFERENCE_FOV_DEGREES = 55;
|
||||
const PIXELS_TO_ANGULAR_SIZE =
|
||||
(2 * Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 180 / 2)) / REFERENCE_VIEWPORT_HEIGHT_PX;
|
||||
|
||||
/**
|
||||
* Extra click forgiveness added to a star's drawn radius, in NDC — roughly 4 px on the
|
||||
@@ -33,25 +26,25 @@ const PICK_NDC_SLOP = 0.01;
|
||||
/**
|
||||
* How many stars the field draws at once, however many the catalogue holds.
|
||||
*
|
||||
* The catalogue reaches as far as its parallaxes do — 68388 stars at 250 pc — but drawing all of
|
||||
* them is a cost paid every frame by every machine, and most of that cost buys 1.5-pixel dots.
|
||||
* So the *data* is the catalogue and the *drawing* is a budget, and the two are allowed to
|
||||
* differ. Everything still exists for search, for flying to, and for hosting planets.
|
||||
* The *data* is the catalogue and the *drawing* is a budget, and the two are allowed to differ:
|
||||
* everything still exists for search, for flying to, and for hosting planets. Which stars fill
|
||||
* the budget follows the view; see {@link selectDrawnStars}.
|
||||
*
|
||||
* Currently set to the whole catalogue, which is what a GPU should be asked to do — this is one
|
||||
* instanced draw call, and a discrete card will not notice it. The budget still exists because
|
||||
* the catalogue is meant to grow past what any machine should draw at once: Gaia alone could
|
||||
* contribute a million stars, and at that point the selection below is what keeps the field
|
||||
* legible rather than a grey wash.
|
||||
*
|
||||
* Machines without a GPU do feel it. A software rasterizer measured here lost about a third of
|
||||
* its frame rate per 12000 stars drawn; if that matters for a deployment, this is the one number
|
||||
* to turn down.
|
||||
* The number is set by what the field looks like, before what it costs. The catalogue is
|
||||
* 423 651 stars since Gaia, and drawn whole the opening view is a grey wash: the additive
|
||||
* blending of that many 1.5-pixel dots buries the labels, the rings on the planet hosts and the
|
||||
* grid. At 150 000 the wash has begun; at this budget the view reads. Measured at 1920 × 1080 on
|
||||
* a Ryzen 7700X, the cost argues the same way. An RTX 4080 draws the whole catalogue in the same
|
||||
* 6.1 ms a frame as this budget, so a discrete card does not notice. The processor's own
|
||||
* two-core Radeon, standing in for an entry-level laptop, pays about 4 ms a frame for every
|
||||
* 100 000 stars: 112 frames a second at this budget, 44 at the whole catalogue, and the same
|
||||
* again under the WebGL2 fallback.
|
||||
*/
|
||||
export const STAR_RENDER_BUDGET = 68388;
|
||||
export const STAR_RENDER_BUDGET = 70_000;
|
||||
|
||||
/**
|
||||
* Radius (parsecs) inside which every star is drawn regardless of brightness.
|
||||
* Radius (parsecs) around the Sun, and around wherever the view is centred, inside which every
|
||||
* star in view is drawn regardless of brightness.
|
||||
*
|
||||
* A pure brightness cut would be defensible — apparent magnitude is exactly "how visible this
|
||||
* is" — but it would drop the solar neighbourhood, because the nearest stars are overwhelmingly
|
||||
@@ -59,36 +52,91 @@ export const STAR_RENDER_BUDGET = 68388;
|
||||
* and the ones that hold the nearby planets, so the neighbourhood is kept whole and the budget
|
||||
* is spent on the brightest of everything beyond it.
|
||||
*
|
||||
* Kept deliberately small against the catalogue's 250 pc reach. The guaranteed core occupies a
|
||||
* thousandth of that volume, so a generous radius spends most of the budget inside it and draws
|
||||
* a dense knot surrounded by nothing — which is a worse picture than the smaller catalogue was.
|
||||
* The same holds wherever the view is looking. Before the drawn set followed the view, a region
|
||||
* 150 pc out drew 49 of the 442 stars within this radius of it, and a route plotted there ran
|
||||
* through waypoints nobody could see or click: Sol to Almach at 8 pc passed 19 stars and drew 6.
|
||||
*
|
||||
* Kept deliberately small against the catalogue's reach. Around the Sun it holds 3 654 stars;
|
||||
* a generous radius spends most of the budget inside it and draws a dense knot surrounded by
|
||||
* nothing.
|
||||
*/
|
||||
export const ALWAYS_DRAWN_RADIUS_PC = 25;
|
||||
|
||||
const COLD_STAR_COLOR = new THREE.Color(0.65, 0.75, 1.0);
|
||||
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
|
||||
const WARM_STAR_COLOR = new THREE.Color(1.0, 0.6, 0.35);
|
||||
export const FOCUS_RADIUS_PC = 25;
|
||||
|
||||
/**
|
||||
* Crude but effective B-V color-index -> RGB tint: hot/blue stars (low/negative index) skew
|
||||
* blue-white, cool/red stars (high index) skew orange-red, matching real spectral colors.
|
||||
* How far past the edges of the frame the drawn stars reach, as a share of the frame's half-width
|
||||
* and half-height: 5° beyond the top and bottom at the 50° field of view, 6° beyond each side.
|
||||
*
|
||||
* `colorIndex` is `null` for the ~10% of stars HYG never photometered. Those fall back to a
|
||||
* value derived from `spectralType`, and to neutral white only when the catalog records no
|
||||
* classification at all — never to 0, which is itself a real color index meaning "hot A-type"
|
||||
* and would paint several hundred red dwarfs blue-white.
|
||||
* The drawn set is chosen for a camera pose and kept until the view has turned or moved half this
|
||||
* far, so the margin is what is on screen by the time it is chosen again. Wider stays whole
|
||||
* through faster turns but spends the budget off screen: at 30 pc from the Sun, where the budget
|
||||
* binds, 0.25 leaves 52 000 of the 70 000 on screen and 0.5 only 44 000.
|
||||
*/
|
||||
export function colorIndexToRgb(colorIndex: number | null, spectralType?: string): THREE.Color {
|
||||
const resolved = colorIndex ?? spectralTypeToColorIndex(spectralType);
|
||||
const color = new THREE.Color();
|
||||
if (resolved === null) {
|
||||
return color.copy(NEUTRAL_STAR_COLOR);
|
||||
export const VIEW_MARGIN = 0.25;
|
||||
|
||||
/** What, besides the brightest stars, the field should be sure to draw. */
|
||||
export interface DrawFocus {
|
||||
/** Where the view is centred. Its neighbourhood is drawn whole, like the Sun's. */
|
||||
readonly centre?: Positioned;
|
||||
/**
|
||||
* Catalogue indices drawn wherever they are and however faint: the selected star, the stars
|
||||
* of a plotted route. Anything the map points at has to be there to be pointed at.
|
||||
*/
|
||||
readonly pinned?: readonly number[];
|
||||
/**
|
||||
* 1 for each catalogue index with known planets. Drawn next after the pinned stars, however
|
||||
* faint: each carries a ring, and a ring around a star that is not drawn circles nothing that
|
||||
* can be clicked.
|
||||
*/
|
||||
readonly hosts?: Uint8Array;
|
||||
/**
|
||||
* The camera's projection times its view matrix. Only stars inside its frame, widened by
|
||||
* {@link VIEW_MARGIN}, are drawn, pinned stars aside; without it, the whole sky is in view.
|
||||
*/
|
||||
readonly view?: THREE.Matrix4;
|
||||
}
|
||||
|
||||
const 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);
|
||||
const SUN: Positioned = { x: 0, y: 0, z: 0 };
|
||||
|
||||
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
|
||||
|
||||
/**
|
||||
* A star's tint in the field: the colour of a blackbody at its effective temperature against the
|
||||
* 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 their
|
||||
* `spectralType`, and to neutral white only when the catalog records no classification at all —
|
||||
* never to 0, which is itself a real color index meaning "hot A-type" and would paint several
|
||||
* hundred red dwarfs blue-white.
|
||||
*
|
||||
* 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, colorSystem?: 'B-V' | 'BP-RP'): THREE.Color {
|
||||
// The distance is only there to say the star is not the Sun; the temperature reads none of the rest.
|
||||
return temperatureTint(effectiveTemperatureK({ magnitude: 0, distancePc: 1, spectralType, colorIndex, colorSystem }));
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function magnitudeToPointSize(magnitude: number): number {
|
||||
const t = THREE.MathUtils.clamp(1 - (magnitude + 2) / 12, 0, 1);
|
||||
@@ -109,7 +157,99 @@ function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometr
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the galaxy-scale star field as instanced camera-facing billboards, one per HYG star,
|
||||
* Reads a render budget override off the page URL (`?stars=20000`), falling back to the default.
|
||||
*
|
||||
* Two uses, one real and one incidental. The real one is a deployment or a machine that cannot
|
||||
* draw the whole catalogue — a number in a URL beats a rebuild. The incidental one is the
|
||||
* end-to-end suite, which runs against a software rasterizer whose frame rate is two orders of
|
||||
* magnitude below a real GPU's: those tests are checking navigation and state, and making them
|
||||
* wait on a rasterizer measures nothing about the app.
|
||||
*/
|
||||
export function starRenderBudgetFromUrl(search: string, fallback = STAR_RENDER_BUDGET): number {
|
||||
const requested = Number(new URLSearchParams(search).get('stars'));
|
||||
return Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses which stars to draw when the catalogue is larger than the budget. In order, until the
|
||||
* budget is spent: the pinned stars wherever they are, then of the stars in view, the planet
|
||||
* hosts, everything within {@link FOCUS_RADIUS_PC} of where the view is centred, everything within
|
||||
* it of the Sun, and the brightest of the rest. Each tier is taken brightest first, so a budget too
|
||||
* small to hold one whole keeps its most visible part.
|
||||
*
|
||||
* Returns indices into the original list, in the order they were chosen. `index` is the
|
||||
* catalogue's brightness index, passed in when the caller already has it rather than sorted again
|
||||
* on every call.
|
||||
*/
|
||||
export function selectDrawnStars(
|
||||
stars: readonly StarRecord[],
|
||||
budget = STAR_RENDER_BUDGET,
|
||||
focus: DrawFocus = {},
|
||||
index: BrightnessIndex = brightnessIndex(stars)
|
||||
): Uint32Array {
|
||||
if (stars.length <= budget) {
|
||||
return Uint32Array.from(stars.keys());
|
||||
}
|
||||
|
||||
// One walk of the brightness order, reading positions laid out in that order, sorts each tier
|
||||
// brightest first as it goes: 2.4-3.1 ms on the real catalogue in Node, against 6.4-8.5 ms
|
||||
// gathering both neighbourhoods in catalogue order and sorting them. Of the stars in no earlier
|
||||
// tier only the first `budget` in view can ever be taken, so past those it looks for the tiers.
|
||||
const { order, positions } = index;
|
||||
const radiusSq = FOCUS_RADIUS_PC * FOCUS_RADIUS_PC;
|
||||
const centre = focus.centre ?? SUN;
|
||||
const view = focus.view?.elements;
|
||||
const reachScale = 1 + VIEW_MARGIN;
|
||||
const hosts: number[] = [];
|
||||
const nearCentre: number[] = [];
|
||||
const nearSun: number[] = [];
|
||||
const rest: number[] = [];
|
||||
for (let at = 0; at < order.length; at++) {
|
||||
const x = positions[at * 3];
|
||||
const y = positions[at * 3 + 1];
|
||||
const z = positions[at * 3 + 2];
|
||||
const dx = x - centre.x;
|
||||
const dy = y - centre.y;
|
||||
const dz = z - centre.z;
|
||||
const isHost = focus.hosts?.[order[at]] === 1;
|
||||
const inCentre = dx * dx + dy * dy + dz * dz <= radiusSq;
|
||||
const inSun = x * x + y * y + z * z <= radiusSq;
|
||||
if (!isHost && !inCentre && !inSun && rest.length >= budget) {
|
||||
continue;
|
||||
}
|
||||
if (view) {
|
||||
// In clip space: in frame when |x| and |y| are within w, widened by the margin. Behind a
|
||||
// perspective camera w is negative, so nothing there passes; an orthographic camera's w is 1.
|
||||
const reach = (view[3] * x + view[7] * y + view[11] * z + view[15]) * reachScale;
|
||||
if (Math.abs(view[0] * x + view[4] * y + view[8] * z + view[12]) > reach || Math.abs(view[1] * x + view[5] * y + view[9] * z + view[13]) > reach) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
(isHost ? hosts : inCentre ? nearCentre : inSun ? nearSun : rest).push(order[at]);
|
||||
}
|
||||
|
||||
const chosen = new Uint8Array(stars.length);
|
||||
const selected: number[] = [];
|
||||
const take = (index: number): void => {
|
||||
if (!chosen[index] && selected.length < budget) {
|
||||
chosen[index] = 1;
|
||||
selected.push(index);
|
||||
}
|
||||
};
|
||||
for (const pinned of focus.pinned ?? []) {
|
||||
if (pinned >= 0 && pinned < stars.length) {
|
||||
take(pinned);
|
||||
}
|
||||
}
|
||||
hosts.forEach(take);
|
||||
nearCentre.forEach(take);
|
||||
nearSun.forEach(take);
|
||||
rest.forEach(take);
|
||||
return Uint32Array.from(selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the galaxy-scale star field as instanced camera-facing billboards, one per drawn star,
|
||||
* coloured by spectral index and sized by magnitude.
|
||||
*
|
||||
* **Why billboards and not `THREE.Points`.** Point primitives are capped at a single pixel on
|
||||
@@ -124,95 +264,89 @@ function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometr
|
||||
* Sizes are angular (`sizeAttenuation = false`), so a star holds the same apparent size however
|
||||
* close the camera gets. That is deliberate and physically right: real stars are unresolvable
|
||||
* point sources, and their apparent size on screen is a function of brightness, not distance.
|
||||
*/
|
||||
/**
|
||||
* Chooses which stars to draw when the catalogue is larger than the budget: everything inside
|
||||
* the neighbourhood radius, then the brightest of the rest until the budget is spent.
|
||||
*
|
||||
* Returns indices into the original list, so the caller can subset the positions that go with
|
||||
* them. Returns them in catalogue order rather than in selection order, purely so the drawn set
|
||||
* is stable and inspectable.
|
||||
* The instance buffers hold the budget, not the catalogue, and are rewritten in place when
|
||||
* {@link refocus} changes which stars fill it.
|
||||
*/
|
||||
/**
|
||||
* Reads a render budget override off the page URL (`?stars=20000`), falling back to the default.
|
||||
*
|
||||
* Two uses, one real and one incidental. The real one is a deployment or a machine that cannot
|
||||
* draw the whole catalogue — a number in a URL beats a rebuild. The incidental one is the
|
||||
* end-to-end suite, which runs against a software rasterizer whose frame rate is two orders of
|
||||
* magnitude below a real GPU's: those tests are checking navigation and state, and making them
|
||||
* wait on a rasterizer measures nothing about the app.
|
||||
*/
|
||||
export function starRenderBudgetFromUrl(search: string, fallback = STAR_RENDER_BUDGET): number {
|
||||
const requested = Number(new URLSearchParams(search).get('stars'));
|
||||
return Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : fallback;
|
||||
}
|
||||
|
||||
export function selectDrawnStars(stars: readonly StarRecord[], budget = STAR_RENDER_BUDGET): Uint32Array {
|
||||
if (stars.length <= budget) {
|
||||
return Uint32Array.from(stars.keys());
|
||||
}
|
||||
|
||||
const near: number[] = [];
|
||||
const far: number[] = [];
|
||||
stars.forEach((star, index) => {
|
||||
(Math.hypot(star.x, star.y, star.z) <= ALWAYS_DRAWN_RADIUS_PC ? near : far).push(index);
|
||||
});
|
||||
|
||||
far.sort((a, b) => stars[a].magnitude - stars[b].magnitude);
|
||||
const selected = near.concat(far.slice(0, Math.max(0, budget - near.length)));
|
||||
selected.sort((a, b) => a - b);
|
||||
return Uint32Array.from(selected);
|
||||
}
|
||||
|
||||
export class StarFieldRenderer {
|
||||
readonly object: THREE.Mesh;
|
||||
/** How many of the catalogue's stars this field actually draws. */
|
||||
readonly drawnCount: number;
|
||||
|
||||
/** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */
|
||||
private readonly perspective = uniform(1);
|
||||
private readonly orthographicScale = uniform(float(0));
|
||||
|
||||
private readonly geometry: THREE.InstancedBufferGeometry;
|
||||
private readonly material: THREE.SpriteNodeMaterial;
|
||||
/** The subset of the catalogue that is drawn, and so the only set that can be clicked. */
|
||||
private readonly stars: readonly StarRecord[];
|
||||
/** Angular diameter per drawn star, in the same order as `stars` — reused for picking. */
|
||||
private readonly angularSizes: Float32Array;
|
||||
private readonly budget: number;
|
||||
private readonly brightness: BrightnessIndex;
|
||||
/**
|
||||
* Colour and angular size of every star in the catalogue, worked out once: a refocus then only
|
||||
* copies them into the instances, 0.7 ms for the budget rather than 5.6 ms computing them again.
|
||||
*/
|
||||
private readonly catalogueColors: Float32Array;
|
||||
private readonly catalogueSizes: Float32Array;
|
||||
|
||||
constructor(catalogue: readonly StarRecord[], cataloguePositions: Float32Array, budget = STAR_RENDER_BUDGET) {
|
||||
const drawn = selectDrawnStars(catalogue, budget);
|
||||
this.stars = drawn.length === catalogue.length ? catalogue : Array.from(drawn, (index) => catalogue[index]);
|
||||
this.drawnCount = this.stars.length;
|
||||
/** Per-instance data, `budget` long; the first `drawnCount` entries are live. */
|
||||
private readonly positionAttribute: THREE.InstancedBufferAttribute;
|
||||
private readonly colorAttribute: THREE.InstancedBufferAttribute;
|
||||
private readonly sizeAttribute: THREE.InstancedBufferAttribute;
|
||||
/** Catalogue index behind each live instance: the set that is drawn, and so the only set that can be clicked. */
|
||||
private drawn: Uint32Array = new Uint32Array(0);
|
||||
|
||||
const stars = this.stars;
|
||||
this.geometry = createQuadGeometry(stars.length);
|
||||
constructor(
|
||||
private readonly catalogue: readonly StarRecord[],
|
||||
private readonly cataloguePositions: Float32Array,
|
||||
budget = STAR_RENDER_BUDGET,
|
||||
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.brightness = brightness ?? brightnessIndex(catalogue);
|
||||
const capacity = Math.min(budget, catalogue.length);
|
||||
this.geometry = createQuadGeometry(0);
|
||||
|
||||
const colors = new Float32Array(stars.length * 3);
|
||||
this.angularSizes = new Float32Array(stars.length);
|
||||
// Repacked only when the drawn set is a subset; otherwise the ETL's buffer is used as-is.
|
||||
const positions =
|
||||
drawn.length === catalogue.length
|
||||
? cataloguePositions
|
||||
: Float32Array.from({ length: drawn.length * 3 }, (_, i) => cataloguePositions[drawn[(i / 3) | 0] * 3 + (i % 3)]);
|
||||
this.positionAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
|
||||
this.colorAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
|
||||
this.sizeAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity), 1);
|
||||
|
||||
stars.forEach((star, index) => {
|
||||
const color = colorIndexToRgb(star.colorIndex, star.spectralType);
|
||||
colors[index * 3] = color.r;
|
||||
colors[index * 3 + 1] = color.g;
|
||||
colors[index * 3 + 2] = color.b;
|
||||
this.angularSizes[index] = magnitudeToPointSize(star.magnitude) * PIXELS_TO_ANGULAR_SIZE;
|
||||
this.catalogueColors = new Float32Array(catalogue.length * 3);
|
||||
this.catalogueSizes = new Float32Array(catalogue.length);
|
||||
catalogue.forEach((star, index) => {
|
||||
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 + 1] = color.g;
|
||||
this.catalogueColors[index * 3 + 2] = color.b;
|
||||
this.catalogueSizes[index] = magnitudeToPointSize(star.magnitude) * PIXELS_TO_ANGULAR_SIZE;
|
||||
});
|
||||
|
||||
const positionAttribute = new THREE.InstancedBufferAttribute(positions, 3);
|
||||
const colorAttribute = new THREE.InstancedBufferAttribute(colors, 3);
|
||||
const sizeAttribute = new THREE.InstancedBufferAttribute(this.angularSizes, 1);
|
||||
|
||||
this.material = new THREE.SpriteNodeMaterial({
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
this.material.sizeAttenuation = false;
|
||||
this.material.positionNode = instancedBufferAttribute(positionAttribute, 'vec3');
|
||||
this.material.scaleNode = instancedBufferAttribute(sizeAttribute, 'float');
|
||||
this.material.colorNode = instancedBufferAttribute(colorAttribute, 'vec3');
|
||||
// The compensation that turns an angular size into a world size is done here rather than by
|
||||
// `sizeAttenuation: false`, which three.js applies only when it is compiling against a
|
||||
// perspective camera (SpriteNodeMaterial.js: `camera.isPerspectiveCamera && sizeAttenuation
|
||||
// === false`). Under an orthographic one it is silently skipped and every star collapses to
|
||||
// a thousandth of a parsec — invisible. Doing the same arithmetic in the node graph, behind
|
||||
// a uniform, lets one material serve both cameras without being recompiled between them.
|
||||
this.material.sizeAttenuation = true;
|
||||
const position = instancedBufferAttribute<'vec3'>(this.positionAttribute, 'vec3');
|
||||
const angularSize = instancedBufferAttribute<'float'>(this.sizeAttribute, 'float');
|
||||
this.material.positionNode = position;
|
||||
// Perspective: a star's world size is its angular size times how far away it is, which is
|
||||
// exactly what the built-in does. Orthographic: distance does not set apparent size at all,
|
||||
// the frustum does, so the same angular size is scaled by the frustum instead.
|
||||
const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate();
|
||||
this.material.scaleNode = angularSize.mul(mix(this.orthographicScale, viewDepth, this.perspective));
|
||||
this.material.colorNode = instancedBufferAttribute<'vec3'>(this.colorAttribute, 'vec3');
|
||||
// Soft radial falloff so each star is a small bright core inside a halo, rather than a
|
||||
// hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre.
|
||||
const radius = uv().sub(vec2(0.5)).length();
|
||||
@@ -222,11 +356,69 @@ export class StarFieldRenderer {
|
||||
// The quad's own bounds sit at the origin and say nothing about where the instances are,
|
||||
// so leaving culling on would drop the whole field whenever the origin left the frustum.
|
||||
this.object.frustumCulled = false;
|
||||
|
||||
this.refocus({});
|
||||
}
|
||||
|
||||
/** Looks up the HYG star id for a given instance index. */
|
||||
/** How many of the catalogue's stars this field is drawing. */
|
||||
get drawnCount(): number {
|
||||
return this.drawn.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalogue indices being drawn. Replaced by a refocus that changes them, never changed in
|
||||
* place, so the same array means the same stars.
|
||||
*/
|
||||
get drawnStars(): Uint32Array {
|
||||
return this.drawn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses the drawn stars again for where the view now is, and rewrites the instance buffers
|
||||
* with them. See {@link selectDrawnStars}.
|
||||
*/
|
||||
refocus(focus: DrawFocus): void {
|
||||
const drawn = selectDrawnStars(this.catalogue, this.budget, focus, this.brightness);
|
||||
// The same stars in the same instances: the buffers already hold them, and a rewrite would
|
||||
// upload 2 MB to the GPU for nothing — which a pan across empty space would do every pass.
|
||||
if (drawn.length === this.drawn.length && drawn.every((index, instance) => index === this.drawn[instance])) {
|
||||
return;
|
||||
}
|
||||
this.drawn = drawn;
|
||||
|
||||
const positions = this.positionAttribute.array as Float32Array;
|
||||
const colors = this.colorAttribute.array as Float32Array;
|
||||
const sizes = this.sizeAttribute.array as Float32Array;
|
||||
this.drawn.forEach((catalogueIndex, instance) => {
|
||||
for (let axis = 0; axis < 3; axis++) {
|
||||
positions[instance * 3 + axis] = this.cataloguePositions[catalogueIndex * 3 + axis];
|
||||
colors[instance * 3 + axis] = this.catalogueColors[catalogueIndex * 3 + axis];
|
||||
}
|
||||
sizes[instance] = this.catalogueSizes[catalogueIndex];
|
||||
});
|
||||
|
||||
this.geometry.instanceCount = this.drawn.length;
|
||||
this.positionAttribute.needsUpdate = true;
|
||||
this.colorAttribute.needsUpdate = true;
|
||||
this.sizeAttribute.needsUpdate = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the field which projection it is being drawn under.
|
||||
*
|
||||
* `halfHeightWorld` is half the orthographic frustum's height in world units; `null` means a
|
||||
* perspective camera, where a star's distance sets its apparent size on its own.
|
||||
*/
|
||||
setProjection(halfHeightWorld: number | null): void {
|
||||
this.perspective.value = halfHeightWorld === null ? 1 : 0;
|
||||
// The world size that subtends the same share of the viewport an angular size would under
|
||||
// the reference field of view: `angular * halfHeight / tan(fov/2)`.
|
||||
this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360);
|
||||
}
|
||||
|
||||
/** Looks up the star id for a given instance index. */
|
||||
starIdAt(instanceIndex: number): number | undefined {
|
||||
return this.stars[instanceIndex]?.id;
|
||||
return instanceIndex >= 0 && instanceIndex < this.drawn.length ? this.catalogue[this.drawn[instanceIndex]].id : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,27 +430,45 @@ export class StarFieldRenderer {
|
||||
* needed: each star is tested against the size it is actually drawn at, so the hit area matches
|
||||
* what the user sees at every zoom level instead of being over-permissive up close and
|
||||
* sub-pixel at the far end of the camera's range.
|
||||
*
|
||||
* Only stars on screen can be picked. The hit area is the drawn size plus a slop of
|
||||
* {@link PICK_NDC_SLOP}, and near an edge that slop reaches past the frame: a click in the
|
||||
* last few pixels of the view used to be able to fly into a system whose star was outside it,
|
||||
* with nothing on screen to explain where it had gone.
|
||||
*/
|
||||
pickAt(pointerNdc: THREE.Vector2, camera: THREE.PerspectiveCamera): number | undefined {
|
||||
const tanHalfFov = Math.tan((camera.fov * Math.PI) / 360);
|
||||
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
|
||||
// it. Under an orthographic camera the frustum does — but `setProjection` sized the sprite
|
||||
// as `angular * halfHeight / tan(REFERENCE_FOV/2)` in the first place, so dividing back out
|
||||
// by that same half-height leaves the reference field of view and nothing else. Both cases
|
||||
// are therefore one formula over a different angle.
|
||||
const perspective = (camera as THREE.PerspectiveCamera).isPerspectiveCamera;
|
||||
const tanHalfFov = Math.tan(((perspective ? (camera as THREE.PerspectiveCamera).fov : REFERENCE_FOV_DEGREES) * Math.PI) / 360);
|
||||
const projected = new THREE.Vector3();
|
||||
const positions = this.positionAttribute.array as Float32Array;
|
||||
const sizes = this.sizeAttribute.array as Float32Array;
|
||||
|
||||
let bestIndex: number | undefined;
|
||||
let bestScore = Infinity;
|
||||
|
||||
for (let index = 0; index < this.stars.length; index++) {
|
||||
const star = this.stars[index];
|
||||
projected.set(star.x, star.y, star.z).project(camera);
|
||||
for (let index = 0; index < this.drawn.length; index++) {
|
||||
projected.set(positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]).project(camera);
|
||||
// Outside the depth range means behind the camera or beyond the far plane; `project`
|
||||
// mirrors points behind the camera onto the screen, so this guard is load-bearing.
|
||||
if (projected.z < -1 || projected.z > 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
|
||||
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
|
||||
const ndcRadius = (0.5 * this.angularSizes[index]) / tanHalfFov + PICK_NDC_SLOP;
|
||||
const dx = (projected.x - pointerNdc.x) * camera.aspect;
|
||||
const 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 dy = projected.y - pointerNdc.y;
|
||||
const score = Math.hypot(dx, dy) / ndcRadius;
|
||||
|
||||
@@ -268,7 +478,7 @@ export class StarFieldRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex === undefined ? undefined : this.stars[bestIndex].id;
|
||||
return bestIndex === undefined ? undefined : this.starIdAt(bestIndex);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
|
||||
|
||||
import { StarLabelOverlay } from './star-label-overlay';
|
||||
|
||||
describe('StarLabelOverlay', () => {
|
||||
@@ -77,6 +79,66 @@ describe('StarLabelOverlay', () => {
|
||||
expect(labels()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('hangs a label on the side it is told to, and can move it across', () => {
|
||||
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0, side: 'left' }]);
|
||||
const object = scene.children[0] as CSS2DObject;
|
||||
expect(labels()[0].classList.contains('map-label--left')).toBe(true);
|
||||
expect(object.center.x).toBe(1);
|
||||
|
||||
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0, side: 'right' }]);
|
||||
expect(labels()[0].classList.contains('map-label--left')).toBe(false);
|
||||
expect(object.center.x).toBe(0);
|
||||
});
|
||||
|
||||
it('brackets one selected point with the mark, moves it, and clears it', () => {
|
||||
overlay.setSelection({ x: 1, y: 2, z: 3 });
|
||||
overlay.setSelection({ x: 4, y: 5, z: 6 });
|
||||
overlay.render(camera);
|
||||
const marks = overlay.domElement.querySelectorAll('.map-select');
|
||||
expect(marks).toHaveLength(1);
|
||||
expect((scene.children[0] as THREE.Object3D).position.toArray()).toEqual([4, 5, 6]);
|
||||
|
||||
overlay.setSelection(null);
|
||||
overlay.render(camera);
|
||||
expect(overlay.domElement.querySelectorAll('.map-select')).toHaveLength(0);
|
||||
expect(scene.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('says a neighbour more quietly than a body of this system', () => {
|
||||
overlay.update([
|
||||
{ id: 'earth', name: 'Earth', kind: 'Planet', x: 1, y: 0, z: 0 },
|
||||
{ id: 'neighbour:1', name: 'Sirius', kind: '2.64 pc', tone: 'ghost', selectStarId: 1, x: 0, y: 1, z: 0 }
|
||||
]);
|
||||
|
||||
const [body, ghost] = labels();
|
||||
expect(body.classList.contains('map-label--ghost')).toBe(false);
|
||||
expect(ghost.classList.contains('map-label--ghost')).toBe(true);
|
||||
expect(ghost.querySelector('.map-label-kind')?.textContent).toBe('2.64 pc');
|
||||
});
|
||||
|
||||
it('makes a label that offers a star a button, and hands back the star it names', () => {
|
||||
const chosen: number[] = [];
|
||||
overlay = new StarLabelOverlay(scene, (starId) => chosen.push(starId));
|
||||
overlay.setSize(800, 600);
|
||||
overlay.update([{ id: 'neighbour:42', name: 'Sirius', kind: '2.64 pc', tone: 'ghost', selectStarId: 42, x: 1, y: 0, z: 0 }]);
|
||||
|
||||
const ghost = labels()[0];
|
||||
expect(ghost.tagName).toBe('BUTTON');
|
||||
// Its two lines are adjacent spans, so without this it is announced as "Sirius2.64 pc".
|
||||
expect(ghost.getAttribute('aria-label')).toBe('Go to Sirius, 2.64 pc away');
|
||||
ghost.click();
|
||||
|
||||
expect(chosen).toEqual([42]);
|
||||
});
|
||||
|
||||
it('leaves a label that offers nothing untouchable, so the scene behind it stays clickable', () => {
|
||||
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 }]);
|
||||
|
||||
const label = labels()[0];
|
||||
expect(label.tagName).toBe('DIV');
|
||||
expect(label.classList.contains('map-label--select')).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves nothing behind when disposed', () => {
|
||||
overlay.update([
|
||||
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
|
||||
|
||||
@@ -15,11 +15,49 @@ export interface LabeledPoint {
|
||||
* is called.
|
||||
*/
|
||||
kind?: string;
|
||||
/**
|
||||
* Which side of the point the text hangs on. Right is the default; left is for a point close
|
||||
* to the right edge of the view, or one whose right-hand text would run into a neighbour's.
|
||||
*/
|
||||
side?: LabelSide;
|
||||
/**
|
||||
* `ghost` is the quieter voice: a star outside the system the camera is in, named so its
|
||||
* direction can be read without leaving. Dimmer, and it can be selected.
|
||||
*/
|
||||
tone?: LabelTone;
|
||||
/**
|
||||
* The star this label offers to fly to. Present makes the label a real button — focusable,
|
||||
* clickable, and the only labels the pointer can reach at all. Whether a given id is
|
||||
* selectable never changes between updates, so the element it needs is settled at creation.
|
||||
*/
|
||||
selectStarId?: number;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export type LabelSide = 'left' | 'right';
|
||||
export type LabelTone = 'normal' | 'ghost';
|
||||
|
||||
/** Where the selection mark sits, in the same scene units as the labels. */
|
||||
export interface SelectionPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
function classesFor(point: Pick<LabeledPoint, 'side' | 'tone' | 'selectStarId'>): string {
|
||||
return [
|
||||
'map-label',
|
||||
point.side === 'left' ? 'map-label--left' : '',
|
||||
point.tone === 'ghost' ? 'map-label--ghost' : '',
|
||||
point.selectStarId === undefined ? '' : 'map-label--select',
|
||||
'whitespace-nowrap font-body'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders DOM-based (CSS2D) name labels anchored to 3D star positions. Labels are added as
|
||||
* children of the main scene (so `CSS2DRenderer` can project them with the same camera) and
|
||||
@@ -31,8 +69,13 @@ export class StarLabelOverlay {
|
||||
|
||||
private readonly cssRenderer = new CSS2DRenderer();
|
||||
private readonly labelObjects = new Map<number | string, CSS2DObject>();
|
||||
private selection?: CSS2DObject;
|
||||
|
||||
constructor(private readonly scene: THREE.Scene) {
|
||||
constructor(
|
||||
private readonly scene: THREE.Scene,
|
||||
/** Called with the star a selectable label names, when it is clicked or keyed. */
|
||||
private readonly onSelectStar?: (starId: number) => void
|
||||
) {
|
||||
this.cssRenderer.domElement.classList.add('star-label-layer');
|
||||
this.domElement = this.cssRenderer.domElement;
|
||||
}
|
||||
@@ -61,12 +104,48 @@ export class StarLabelOverlay {
|
||||
const existing = this.labelObjects.get(point.id);
|
||||
if (existing) {
|
||||
existing.position.set(point.x, point.y, point.z);
|
||||
this.applyPresentation(existing, point);
|
||||
} else {
|
||||
this.addLabel(point);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves one label that is already up, without going through `update`. For labels whose place
|
||||
* is fixed relative to the camera rather than to anything in the scene: they have to be
|
||||
* recomputed every frame, and rebuilding the whole label set at that rate would throw away
|
||||
* the diffing that keeps the DOM still.
|
||||
*/
|
||||
moveLabel(id: number | string, x: number, y: number, z: number): void {
|
||||
this.labelObjects.get(id)?.position.set(x, y, z);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the selected object in the scene: two thin arcs bracketing the point, the one thing
|
||||
* borrowed from the ARK's control disc. `null` clears it. Kept out of `update` because it is
|
||||
* a different rhythm — labels change on their own cadence, the mark follows a moving body
|
||||
* every frame.
|
||||
*/
|
||||
setSelection(point: SelectionPoint | null): void {
|
||||
if (!point) {
|
||||
if (this.selection) {
|
||||
this.scene.remove(this.selection);
|
||||
this.selection.element.remove();
|
||||
this.selection = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!this.selection) {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'map-select';
|
||||
element.setAttribute('aria-hidden', 'true');
|
||||
this.selection = new CSS2DObject(element);
|
||||
this.scene.add(this.selection);
|
||||
}
|
||||
this.selection.position.set(point.x, point.y, point.z);
|
||||
}
|
||||
|
||||
render(camera: THREE.Camera): void {
|
||||
this.cssRenderer.render(this.scene, camera);
|
||||
}
|
||||
@@ -75,15 +154,30 @@ export class StarLabelOverlay {
|
||||
for (const [id, object] of this.labelObjects) {
|
||||
this.removeLabel(id, object);
|
||||
}
|
||||
this.setSelection(null);
|
||||
}
|
||||
|
||||
private addLabel(point: LabeledPoint): void {
|
||||
const element = document.createElement('div');
|
||||
// A selectable label is a real button, so it is reachable by keyboard and announced as an
|
||||
// action rather than as text that happens to respond to a click.
|
||||
const element = document.createElement(point.selectStarId === undefined ? 'div' : 'button');
|
||||
if (point.selectStarId !== undefined) {
|
||||
const starId = point.selectStarId;
|
||||
(element as HTMLButtonElement).type = 'button';
|
||||
// Read out as a sentence rather than as the two lines run together — the name and the
|
||||
// distance are adjacent spans, so the default accessible name is "Sirius2.64 pc" — and
|
||||
// said as the action it is, since nothing else on screen says these labels are doors.
|
||||
element.setAttribute('aria-label', `Go to ${point.name}${point.kind ? `, ${point.kind} away` : ''}`);
|
||||
element.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
this.onSelectStar?.(starId);
|
||||
});
|
||||
}
|
||||
// Classes assigned directly since this element lives outside Angular's view encapsulation
|
||||
// (see the class comment above). The offset and leader line live in `.map-label` itself:
|
||||
// CSS2DRenderer rewrites this element's inline transform every frame, so a translate here
|
||||
// would be overwritten — the margin is the offset it cannot touch.
|
||||
element.className = 'map-label whitespace-nowrap font-body';
|
||||
element.className = classesFor(point);
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'map-label-name';
|
||||
@@ -98,16 +192,25 @@ export class StarLabelOverlay {
|
||||
}
|
||||
|
||||
const object = new CSS2DObject(element);
|
||||
// Anchor the label's left edge at the point, vertically centred. The default center of
|
||||
// Anchor the label's near edge at the point, vertically centred. The default center of
|
||||
// (0.5, 0.5) makes CSS2DRenderer emit translate(-50%,-50%), keeping the box centred on the
|
||||
// star — under which `.map-label`'s margin offset only nudges the centred box sideways and
|
||||
// the leader line points at empty space half the label's width from the star.
|
||||
object.center.set(0, 0.5);
|
||||
this.applyPresentation(object, point);
|
||||
object.position.set(point.x, point.y, point.z);
|
||||
this.scene.add(object);
|
||||
this.labelObjects.set(point.id, object);
|
||||
}
|
||||
|
||||
/** Right-hand text hangs its left edge on the point; left-hand text hangs its right edge. */
|
||||
private applyPresentation(object: CSS2DObject, point: LabeledPoint): void {
|
||||
object.center.set(point.side === 'left' ? 1 : 0, 0.5);
|
||||
const wanted = classesFor(point);
|
||||
if (object.element.className !== wanted) {
|
||||
object.element.className = wanted;
|
||||
}
|
||||
}
|
||||
|
||||
private removeLabel(id: number | string, object: CSS2DObject): void {
|
||||
this.scene.remove(object);
|
||||
object.element.remove();
|
||||
|
||||
@@ -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(' · ');
|
||||
}
|
||||
@@ -67,28 +67,6 @@ describe('StarmapHudComponent', () => {
|
||||
expect(emitted).toEqual(['galactic', 'galaxy']);
|
||||
});
|
||||
|
||||
it('renders the readout panel from its inputs', () => {
|
||||
fixture.componentRef.setInput('level', 'galactic');
|
||||
fixture.componentRef.setInput('eyebrow', 'Galactic Scale');
|
||||
fixture.componentRef.setInput('title', 'Milky Way');
|
||||
fixture.componentRef.setInput('subtitle', 'Barred spiral');
|
||||
fixture.componentRef.setInput('readouts', [{ label: 'Arms', value: '5' }]);
|
||||
fixture.componentRef.setInput('note', 'Illustrative model.');
|
||||
fixture.componentRef.setInput('range', '21.5 kpc');
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||
for (const expected of ['Galactic Scale', 'Milky Way', 'Barred spiral', 'Arms', '5', 'Illustrative model.', '21.5 kpc']) {
|
||||
expect(text).toContain(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves out the optional lines it was given nothing for', () => {
|
||||
const host = render('galaxy');
|
||||
expect(host.querySelector('dl')).toBeNull();
|
||||
expect(host.textContent).not.toContain('undefined');
|
||||
});
|
||||
|
||||
it('names what the view is holding on the banner across the top', () => {
|
||||
fixture.componentRef.setInput('level', 'system');
|
||||
fixture.componentRef.setInput('title', 'Sol');
|
||||
@@ -101,4 +79,17 @@ describe('StarmapHudComponent', () => {
|
||||
// An empty nameplate is worse than none: it reads as a selection that failed to resolve.
|
||||
expect(render('galaxy').querySelector('[data-testid="hud-banner"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the scale it is given, as a bar of that width', () => {
|
||||
fixture.componentRef.setInput('scale', { label: '10 pc', widthPx: 100 });
|
||||
const bar = render('galaxy').querySelector<HTMLElement>('[data-testid="hud-scale"]');
|
||||
|
||||
expect(bar?.getAttribute('aria-label')).toBe('Scale: 10 pc');
|
||||
expect(bar?.textContent?.trim()).toBe('10 pc');
|
||||
expect(bar?.querySelector<HTMLElement>('span:last-child')?.style.width).toBe('100px');
|
||||
});
|
||||
|
||||
it('shows no scale bar while there is no scale to show', () => {
|
||||
expect(render('galaxy').querySelector('[data-testid="hud-scale"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||
|
||||
import { ScaleBar } from '../../shared/format/scale-bar';
|
||||
import { ViewLevel } from '../../shared/state/navigation.store';
|
||||
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
|
||||
|
||||
export interface HudReadout {
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
/**
|
||||
* True when the figure was computed from other measurements rather than catalogued directly.
|
||||
* Marked in the panel and explained in its footnote, so a reasoned number is never mistaken for
|
||||
* an observed one.
|
||||
*/
|
||||
readonly derived?: boolean;
|
||||
}
|
||||
|
||||
interface LadderStep {
|
||||
readonly level: ViewLevel;
|
||||
readonly label: string;
|
||||
@@ -31,9 +21,9 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* The map's heads-up display: the scale ladder down the left, the readout panel across the
|
||||
* bottom, a centre reticle on whatever the camera is holding, and the frame brackets around
|
||||
* the whole viewport.
|
||||
* The top of the map's heads-up display: the scale ladder on the left with the scale bar under
|
||||
* it, the nameplate across the centre, and a centre reticle on whatever the camera is holding.
|
||||
* Readouts and tools live in the dock along the bottom (`HudDockComponent`).
|
||||
*
|
||||
* Purely presentational — every value arrives as an input and the only thing it emits is a
|
||||
* request to move to another scale. The scene owns the camera and decides what that means.
|
||||
@@ -49,8 +39,6 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
||||
imports: [ReticleIconComponent],
|
||||
host: { class: 'pointer-events-none absolute inset-0 block select-none' },
|
||||
template: `
|
||||
<div class="hud-vignette absolute inset-0"></div>
|
||||
|
||||
@if (showReticle()) {
|
||||
<!-- The same circle-and-ticks reticle the search field wears, scaled up: one lock mark
|
||||
for the whole instrument, whether it is holding a query or a body. -->
|
||||
@@ -58,8 +46,8 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
||||
}
|
||||
|
||||
<!-- Top rail: which scale the view is at, and what it is holding. Both sit on one line
|
||||
across the top of the display, clear of the search field above them. -->
|
||||
<nav aria-label="Map scale" class="hud-brackets hud-surface pointer-events-auto absolute top-16 left-6 flex items-stretch divide-x divide-border/40">
|
||||
across the top of the display; the search lives in the dock below, so nothing sits above. -->
|
||||
<nav aria-label="Map scale" class="hud-brackets hud-surface pointer-events-auto absolute top-6 left-6 flex items-stretch divide-x divide-border/40">
|
||||
@for (step of ladder(); track step.level) {
|
||||
@if (step.reachable) {
|
||||
<button
|
||||
@@ -81,62 +69,35 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
||||
}
|
||||
</nav>
|
||||
|
||||
@if (scale(); as bar) {
|
||||
<!-- The map's scale bar, under the rail that names the scale: a round length, measured at the
|
||||
depth the view is centred on, since under perspective every depth has its own. -->
|
||||
<div data-testid="hud-scale" role="img" [attr.aria-label]="'Scale: ' + bar.label" class="absolute top-16 left-6 flex flex-col items-start gap-1">
|
||||
<span class="type-label text-muted tabular-nums">{{ bar.label }}</span>
|
||||
<span class="block h-1.5 border-x border-b border-accent/70" [style.width.px]="bar.widthPx"></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (title()) {
|
||||
<!-- Hidden below lg: the readout panel names the same thing, and at narrower widths a
|
||||
long star name runs into the scale rail on its left and under the object card on its
|
||||
right — all three share the top-16 line. -->
|
||||
<div class="absolute top-16 left-1/2 hidden -translate-x-1/2 lg:block">
|
||||
right — all three share the top-6 line. -->
|
||||
<div class="absolute top-6 left-1/2 hidden -translate-x-1/2 lg:block">
|
||||
<div data-testid="hud-banner" class="hud-brackets hud-acquire hud-surface flex items-center gap-2.5 px-6 py-1.5">
|
||||
<app-reticle-icon class="h-3 w-3 shrink-0 text-accent" />
|
||||
<span class="text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="absolute right-6 bottom-6 left-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div class="hud-brackets hud-acquire hud-surface max-w-lg px-4 py-3">
|
||||
<p class="type-label text-muted">{{ eyebrow() }}</p>
|
||||
<p data-testid="hud-title" class="mt-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
|
||||
@if (subtitle()) {
|
||||
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
|
||||
}
|
||||
@if (readouts().length) {
|
||||
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
||||
@for (readout of readouts(); track readout.label) {
|
||||
<div>
|
||||
<dt class="type-label text-muted">{{ readout.label }}@if (readout.derived) {<span class="text-accent/80" aria-hidden="true">*</span>}</dt>
|
||||
<dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
@if (note() || hasDerived()) {
|
||||
<p class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted">@if (hasDerived()) {<span class="text-accent/80">*</span> Derived, not catalogued. }{{ note() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="hud-brackets hud-acquire hud-surface px-4 py-3 text-right">
|
||||
<p class="type-label text-muted">Range</p>
|
||||
<p class="mt-1 text-lg text-accent tabular-nums">{{ range() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class StarmapHudComponent {
|
||||
readonly level = input.required<ViewLevel>();
|
||||
/** Headline for the readout panel — the selected star, or the name of the current scale. */
|
||||
/** What the view is holding, for the nameplate — the selected star, or nothing. */
|
||||
readonly title = input('');
|
||||
readonly subtitle = input('');
|
||||
readonly eyebrow = input('');
|
||||
readonly readouts = input<readonly HudReadout[]>([]);
|
||||
/** Standing caveat for the current view, e.g. that galactic structure is a model. */
|
||||
readonly note = input('');
|
||||
/** Whether any readout needs the derived-value footnote. */
|
||||
readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived));
|
||||
|
||||
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
|
||||
readonly range = input('');
|
||||
readonly showReticle = input(true);
|
||||
/** The scale bar for the current zoom, worked out by the scene, which knows the camera. */
|
||||
readonly scale = input<ScaleBar | null>(null);
|
||||
|
||||
readonly levelSelected = output<ViewLevel>();
|
||||
|
||||
|
||||
@@ -4,9 +4,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
||||
import {
|
||||
bodyMarkerRadiusAu,
|
||||
DEFAULT_STAR_MARKER_RADIUS_AU,
|
||||
starGlowExtentAu,
|
||||
starMarkerRadiusAu,
|
||||
closestApproachAu,
|
||||
systemFrameRadiusAu,
|
||||
systemFramingDistanceAu,
|
||||
systemGridRingsAu,
|
||||
@@ -19,39 +17,10 @@ import {
|
||||
const TRAPPIST_1 = { innermost: 0.01154, outermost: 0.06189 };
|
||||
const GL_357 = { innermost: 0.035, outermost: 0.204 };
|
||||
const SOLAR = { innermost: 0.387, outermost: 30.07 };
|
||||
|
||||
describe('starMarkerRadiusAu', () => {
|
||||
it('never reaches the innermost orbit', () => {
|
||||
for (const { innermost } of [TRAPPIST_1, GL_357, SOLAR]) {
|
||||
expect(starMarkerRadiusAu(innermost)).toBeLessThan(innermost);
|
||||
}
|
||||
});
|
||||
|
||||
it('shrinks to fit a compact system whose orbits were all inside the old fixed radius', () => {
|
||||
// Every TRAPPIST-1 orbit is inside 0.2 AU, so the star used to swallow the entire system.
|
||||
expect(starMarkerRadiusAu(TRAPPIST_1.innermost)).toBeLessThan(TRAPPIST_1.outermost);
|
||||
expect(starMarkerRadiusAu(GL_357.innermost)).toBeLessThan(GL_357.outermost);
|
||||
});
|
||||
|
||||
it('never grows beyond the default, however wide the system', () => {
|
||||
expect(starMarkerRadiusAu(SOLAR.innermost)).toBeLessThanOrEqual(DEFAULT_STAR_MARKER_RADIUS_AU);
|
||||
expect(starMarkerRadiusAu(500)).toBe(DEFAULT_STAR_MARKER_RADIUS_AU);
|
||||
});
|
||||
|
||||
it('scales in proportion to the innermost orbit', () => {
|
||||
expect(starMarkerRadiusAu(0.02) / starMarkerRadiusAu(0.01)).toBeCloseTo(2, 9);
|
||||
});
|
||||
|
||||
it('falls back to the default when there are no planets to scale against', () => {
|
||||
for (const innermost of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
expect(starMarkerRadiusAu(innermost)).toBe(DEFAULT_STAR_MARKER_RADIUS_AU);
|
||||
}
|
||||
});
|
||||
|
||||
it('stays positive for an extremely tight orbit', () => {
|
||||
expect(starMarkerRadiusAu(0.0001)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
/** The solar system as the map draws it: out to Eris's semi-major axis, 67.93 AU. */
|
||||
const SOLAR_TO_ERIS = { innermost: 0.387, outermost: 67.93 };
|
||||
/** Eris's aphelion, a(1 + e) = 67.934 x 1.4382: past the 80 AU ring its semi-major axis gives. */
|
||||
const ERIS_APHELION_AU = 97.7;
|
||||
|
||||
describe('systemFramingDistanceAu', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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', () => {
|
||||
/** 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 } {
|
||||
const rings = systemGridRingsAu(outermostOrbitAu);
|
||||
const ring = rings[rings.length - 1];
|
||||
@@ -223,14 +132,15 @@ describe('the grid and the framing together', () => {
|
||||
const VIEWPORTS: SystemViewport[] = [
|
||||
{ fovDegrees: 50, aspect: 1.78 },
|
||||
{ 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', () => {
|
||||
// The whole point of framing against the grid rather than the orbits: before this, 368 of
|
||||
// the 371 systems in the datasets drew a grid wider than the view that was meant to hold it.
|
||||
for (const viewport of VIEWPORTS) {
|
||||
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) {
|
||||
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, SOLAR_TO_ERIS, { outermost: 1 }, { outermost: 12.4 }]) {
|
||||
const { ring, frame } = fit(outermost, viewport);
|
||||
expect(ring).toBeLessThan(frame);
|
||||
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', () => {
|
||||
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) {
|
||||
expect(fit(outermost).ring).toBeGreaterThan(outermost);
|
||||
@@ -254,78 +172,50 @@ describe('the grid and the framing together', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('star and framing together', () => {
|
||||
it('gives compact and wide systems a comparable apparent star size', () => {
|
||||
// Both scale with the system, so the star subtends a similar angle either way — the point
|
||||
// of deriving them from the same measurements rather than fixing them.
|
||||
const apparent = ({ innermost, outermost }: { innermost: number; outermost: number }) =>
|
||||
starMarkerRadiusAu(innermost) / systemFramingDistanceAu(outermost);
|
||||
|
||||
const compact = apparent(TRAPPIST_1);
|
||||
const midRange = apparent(GL_357);
|
||||
|
||||
expect(compact).toBeGreaterThan(0);
|
||||
expect(compact / midRange).toBeGreaterThan(0.25);
|
||||
expect(compact / midRange).toBeLessThan(4);
|
||||
});
|
||||
|
||||
it('always leaves the innermost orbit outside the star, at every scale', () => {
|
||||
for (const innermost of [0.005, 0.01, 0.05, 0.2, 1, 5, 40]) {
|
||||
expect(starMarkerRadiusAu(innermost)).toBeLessThan(innermost);
|
||||
}
|
||||
describe('closestApproachAu', () => {
|
||||
it('keeps the camera three radii out from a giant, and at the old floor for the Sun', () => {
|
||||
expect(closestApproachAu(0.00465)).toBe(0.05);
|
||||
expect(closestApproachAu(2.72)).toBeCloseTo(8.16, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bodyMarkerRadiusAu', () => {
|
||||
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', () => {
|
||||
const wide = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU);
|
||||
const compact = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU / 100);
|
||||
|
||||
expect(compact / wide).toBeCloseTo(0.01, 6);
|
||||
it('draws a body at its true size', () => {
|
||||
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM)).toBeCloseTo(EARTH_RADIUS_KM / KM_PER_AU, 12);
|
||||
expect(bodyMarkerRadiusAu(696340)).toBeCloseTo(0.00465, 5); // the Sun
|
||||
});
|
||||
|
||||
it('keeps a marker far smaller than the orbits it sits on, at any scale', () => {
|
||||
// A fixed 0.09 AU marker inside Gl 357's 0.204 AU system was wider than the orbits, so one
|
||||
// planet swallowed the whole view.
|
||||
for (const span of [0.06, 0.204, 1, 30.07, 800]) {
|
||||
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeLessThan(span / 5);
|
||||
}
|
||||
it('keeps a moon smaller than its planet and outside it, which the exaggeration did not', () => {
|
||||
// Jupiter and Ganymede both ran past the old 0.09 AU ceiling and came out one size, so
|
||||
// Ganymede orbited inside Jupiter; Phobos and Triton sat entirely within Mars and Neptune.
|
||||
const jupiter = bodyMarkerRadiusAu(69911);
|
||||
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', () => {
|
||||
const apparent = (span: number) => bodyMarkerRadiusAu(EARTH_RADIUS_KM, span) / systemFramingDistanceAu(span);
|
||||
it('keeps Phobos outside Mars, where a marker scaled to the system buried it', () => {
|
||||
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', () => {
|
||||
const jupiter = bodyMarkerRadiusAu(69911, SOLAR_SPAN_AU);
|
||||
const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU);
|
||||
|
||||
expect(jupiter).toBeGreaterThan(pluto);
|
||||
expect(bodyMarkerRadiusAu(69911)).toBeGreaterThan(bodyMarkerRadiusAu(1188));
|
||||
});
|
||||
|
||||
it('falls back to the smallest marker for a body with no known radius', () => {
|
||||
const unknown = bodyMarkerRadiusAu(undefined, SOLAR_SPAN_AU);
|
||||
const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU);
|
||||
|
||||
expect(unknown).toBeGreaterThan(0);
|
||||
expect(unknown).toBeLessThanOrEqual(pluto);
|
||||
});
|
||||
|
||||
it('treats a missing span as the reference scale rather than collapsing to zero', () => {
|
||||
for (const span of [0, -5, Number.NaN]) {
|
||||
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeGreaterThan(0);
|
||||
it('falls back to an Earth for a body with no published radius', () => {
|
||||
for (const nothing of [undefined, 0, -1]) {
|
||||
expect(bodyMarkerRadiusAu(nothing as number | undefined)).toBeCloseTo(EARTH_RADIUS_KM / KM_PER_AU, 12);
|
||||
}
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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
|
||||
* framed from a distance floor far larger than the system itself.
|
||||
*
|
||||
* Both quantities are therefore derived from the system's own scale. Because the star and the
|
||||
* camera scale together, a compact system ends up looking like a wide one: same apparent star,
|
||||
* same apparent spread of orbits.
|
||||
* The camera's distance is therefore derived from the system's own scale. The star is not: it is
|
||||
* drawn at its own radius, like every body here, and the framing only makes room for it when the
|
||||
* 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
|
||||
* 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 {
|
||||
fovDegrees: 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 };
|
||||
@@ -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.
|
||||
*
|
||||
* Generous enough to frame the solar system out to Pluto in any window shape, which needs 120 AU
|
||||
* on a landscape display and 140 on a portrait one once the camera's real field of view is
|
||||
* accounted for. Only genuinely pathological systems reach it now — the handful with
|
||||
* directly-imaged companions hundreds of AU out — and those still arrive framed on their inner
|
||||
* region, with the orbit controls reaching far enough to pull back to the rest.
|
||||
* Generous enough to frame the solar system out to Eris in any window a reader holds: Eris's
|
||||
* aphelion, 97.7 AU, the furthest it draws, needs 235 AU on a landscape display and 508 on a 390
|
||||
* by 844 phone once the camera's real field of view is accounted for, and 600 holds it down to an
|
||||
* aspect of 0.39. At 500, framed on the 80 AU grid ring inside that aphelion, a phone arrived with
|
||||
* 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. */
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Radius (AU) to draw the system's star at, given its innermost orbit.
|
||||
*
|
||||
* Never larger than {@link DEFAULT_STAR_MARKER_RADIUS_AU}, and never large enough to reach the
|
||||
* closest orbit. Falls back to that default when the system has no planets, since there is
|
||||
* then nothing for the star to crowd.
|
||||
*/
|
||||
export function starMarkerRadiusAu(innermostOrbitAu: number): number {
|
||||
if (!Number.isFinite(innermostOrbitAu) || innermostOrbitAu <= 0) {
|
||||
return DEFAULT_STAR_MARKER_RADIUS_AU;
|
||||
}
|
||||
return Math.min(DEFAULT_STAR_MARKER_RADIUS_AU, innermostOrbitAu * STAR_RADIUS_TO_INNERMOST_ORBIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Radius, in AU, that the camera can see at the star's own distance — the half-height of the
|
||||
* view frustum where the system sits, along whichever screen axis is tighter.
|
||||
@@ -150,20 +139,6 @@ export function systemFrameRadiusAu(distanceAu: number, viewport: SystemViewport
|
||||
return distanceAu * tightHalfExtent(viewport);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extent (AU) of the star's glow sprite — how wide it is drawn, not its radius.
|
||||
*
|
||||
* Normally a multiple of the star's own radius, so a compact system keeps the corona it has.
|
||||
* Floored against the framed radius, so a star framed from far enough out to hold its whole
|
||||
* system still reads as a bright point rather than disappearing into it. `glowScale` lets a
|
||||
* caller dim the halo for stars drawn without a real photograph.
|
||||
*/
|
||||
export function starGlowExtentAu(markerRadiusAu: number, frameRadiusAu: number, glowScale = 1): number {
|
||||
const fromStar = markerRadiusAu * STAR_GLOW_TO_MARKER * glowScale;
|
||||
const fromFrame = Number.isFinite(frameRadiusAu) && frameRadiusAu > 0 ? frameRadiusAu * MIN_STAR_GLOW_TO_FRAME : 0;
|
||||
return Math.max(fromStar, fromFrame);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance (AU) to settle the camera at so that `framedRadiusAu` fits in view with a margin
|
||||
* around it.
|
||||
@@ -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
|
||||
* grid overflowing the frame in 368 of the 371 systems the datasets contain.
|
||||
*
|
||||
* Callers pass the outermost thing actually drawn, which is the reference grid's outer ring
|
||||
* rather than the outermost orbit — the ring is always the wider of the two, by construction.
|
||||
* Callers pass the outermost thing actually drawn: the reference grid's outer ring, which runs past
|
||||
* 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) {
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
const TARGET_GRID_RING_COUNT = 8;
|
||||
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
|
||||
* scaled against. The marker constants below were tuned by eye at this scale.
|
||||
* A body is drawn at its true size. Astronomical Unit in kilometres, and what a body with neither
|
||||
* 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;
|
||||
|
||||
/** 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;
|
||||
const KM_PER_AU = 149597870.7;
|
||||
const DEFAULT_BODY_RADIUS_KM = 6371;
|
||||
|
||||
/**
|
||||
* Radius (AU) to draw a planet, moon or exoplanet marker at, scaled to the system it sits in.
|
||||
*
|
||||
* Marker sizes are deliberately exaggerated — a true-scale Earth would be invisible next to its
|
||||
* own orbit — but the exaggeration has to be relative to the system, not absolute. Fixed AU
|
||||
* sizes tuned against the solar system's 30 AU span become grotesque in a system a hundredth
|
||||
* that size: a marker of 0.09 AU inside a 0.2 AU system is wider than the orbits it sits on, so
|
||||
* a single planet swallows the entire view.
|
||||
*
|
||||
* Scaling by the span keeps every system looking like the solar system does: orbits legible,
|
||||
* planets as small dots on them.
|
||||
* 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.
|
||||
*/
|
||||
export function bodyMarkerRadiusAu(radiusKm: number | undefined, systemSpanAu: number): number {
|
||||
const span = Number.isFinite(systemSpanAu) && systemSpanAu > 0 ? systemSpanAu : REFERENCE_SYSTEM_SPAN_AU;
|
||||
const atReferenceScale = radiusKm ? clamp(radiusKm / MARKER_RADIUS_KM_PER_AU, MIN_MARKER_RADIUS_AU, MAX_MARKER_RADIUS_AU) : MIN_MARKER_RADIUS_AU;
|
||||
export const SUN_RADIUS_AU = 696340 / KM_PER_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');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expect(render(earth).textContent).toContain('photography');
|
||||
});
|
||||
@@ -98,7 +108,9 @@ describe('SystemObjectCardComponent', () => {
|
||||
appearance: appearance({ equilibriumTemperatureK: null }),
|
||||
});
|
||||
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', () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core';
|
||||
|
||||
import { BodyDetailViewModel } from '../body-detail/body-detail.model';
|
||||
import { bodyReadouts } from '../body-detail/body-readouts';
|
||||
import { BookmarksStore } from '../../shared/state/bookmarks.store';
|
||||
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
|
||||
import { ReadoutSectionsComponent } from '../body-detail/readout-sections.component';
|
||||
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
||||
|
||||
@@ -19,7 +21,7 @@ import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
||||
@Component({
|
||||
selector: 'app-system-object-card',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ChevronIconComponent, ReadoutSectionsComponent],
|
||||
imports: [BookmarkIconComponent, ChevronIconComponent, ReadoutSectionsComponent],
|
||||
// Positioned and full-bleed like the HUD's own host, so the panel inside it resolves against
|
||||
// the scene rather than against whatever box the inline default would have left it in — which
|
||||
// put the card off the bottom-left corner of the viewport entirely.
|
||||
@@ -29,13 +31,25 @@ import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
||||
never fights the absolute placement. Same organism as the detail page's info panel:
|
||||
header, shared readout sections, and a route rail — there at the top, here at the
|
||||
bottom, because here the route is the next step rather than the way back. -->
|
||||
<div class="pointer-events-auto absolute top-16 right-6 w-80 max-w-[calc(100%-3rem)]">
|
||||
<div class="pointer-events-auto absolute top-6 right-6 w-80 max-w-[calc(100%-3rem)]">
|
||||
<div data-testid="object-card" class="hud-brackets hud-acquire hud-surface font-body text-text">
|
||||
<div class="flex items-start justify-between gap-3 px-4 pt-4 pb-3">
|
||||
<header class="min-w-0">
|
||||
<p class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</p>
|
||||
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||
<!-- Wrapped, not truncated, as on the detail page: a designation's last digits are the
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
[attr.aria-label]="(bookmarks.has('body', body().id) ? 'Forget ' : 'Keep ') + body().name"
|
||||
[attr.aria-pressed]="bookmarks.has('body', body().id)"
|
||||
(click)="bookmarks.toggle({ kind: 'body', id: body().id, name: body().name })"
|
||||
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
[class]="bookmarks.has('body', body().id) ? 'text-accent' : 'text-muted hover:text-accent'"
|
||||
>
|
||||
<app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('body', body().id)" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="dismissed.emit()"
|
||||
@@ -69,5 +83,7 @@ export class SystemObjectCardComponent {
|
||||
/** Request for the full `/body/:id` route. */
|
||||
readonly openRequested = output<void>();
|
||||
|
||||
readonly bookmarks = inject(BookmarksStore);
|
||||
|
||||
readonly readouts = computed(() => bodyReadouts(this.body()));
|
||||
}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
/// <reference types="node" />
|
||||
|
||||
import { DEFAULT_EPOCH_JD } from '../../shared/astro/constants';
|
||||
import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
||||
import { readFileSync } from 'node:fs';
|
||||
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 { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
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. */
|
||||
const TRAPPIST_1B_SEMI_MAJOR_AXIS_AU = 0.01154;
|
||||
@@ -164,7 +174,8 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => {
|
||||
argumentOfPeriapsisDeg: 0,
|
||||
meanAnomalyAtEpochDeg: 0,
|
||||
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', () => {
|
||||
@@ -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.
|
||||
const atEquinox: BodyRecord = { ...EARTH, orbit: { ...EARTH.orbit, eccentricity: 0 } };
|
||||
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;
|
||||
expect(p.x).toBeCloseTo(1, 6);
|
||||
@@ -234,7 +246,9 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => {
|
||||
name: 'Jupiter',
|
||||
kind: 'planet',
|
||||
radiusKm: 69911,
|
||||
orbit: { semiMajorAxisAu: 5.2, eccentricity: 0.048, inclinationDeg: 1.3, longitudeOfAscendingNodeDeg: 100, argumentOfPeriapsisDeg: 275, meanAnomalyAtEpochDeg: 20, epochJd: DEFAULT_EPOCH_JD }
|
||||
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. */
|
||||
@@ -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 { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
||||
import { gmForParent } from '../../shared/astro/constants';
|
||||
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
|
||||
import { MARKER_TEXTURE_HEIGHT, MARKER_TEXTURE_WIDTH, planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
||||
import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
|
||||
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
||||
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 { 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 { PolarGridPlane, TetherField } from './grid-plane';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
@@ -18,6 +21,8 @@ export interface SystemMember {
|
||||
id: string;
|
||||
kind: SystemMemberKind;
|
||||
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);
|
||||
@@ -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
|
||||
* obliquity about the shared vernal-equinox axis. Solar-system elements come from Horizons
|
||||
* against the ecliptic, so this is their frame.
|
||||
* obliquity about the shared vernal-equinox axis. The planets' and the Moon's mean elements are
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -88,21 +120,21 @@ function colorForKind(kind: SystemMemberKind): THREE.Color {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
/** Marks orbit lines so the whole layer can be toggled without touching the bodies. */
|
||||
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 {
|
||||
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({
|
||||
color: colorForKind(kind),
|
||||
@@ -110,45 +142,222 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame
|
||||
opacity: ORBIT_LINE_OPACITY_BY_KIND[kind]
|
||||
});
|
||||
|
||||
return new THREE.Line(geometry, material);
|
||||
const line = new THREE.Line(geometry, material);
|
||||
line.name = ORBIT_LINE_NAME;
|
||||
line.userData = { semiMajorAxisAu: elements.semiMajorAxisAu, eccentricity: elements.eccentricity };
|
||||
orientOrbit(line.quaternion, elements, frame);
|
||||
return line;
|
||||
}
|
||||
|
||||
const ORBIT_LINE_SEGMENTS = 128;
|
||||
|
||||
/** The orbit's ellipse in its own plane, periapsis along +X, written into `positions`. */
|
||||
function ellipseInItsPlane(elements: OrbitalElements, positions: Float32Array): Float32Array {
|
||||
orbitEllipsePoints({ ...elements, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0 }, ORBIT_LINE_SEGMENTS).forEach((point, index) => {
|
||||
positions[index * 3] = point.x;
|
||||
positions[index * 3 + 1] = point.y;
|
||||
positions[index * 3 + 2] = point.z;
|
||||
});
|
||||
return positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* A marker sphere, surfaced with the body's own derived appearance rather than a flat category
|
||||
* colour — so a system reads as a set of distinct worlds at a glance, and the colour of each is
|
||||
* a consequence of its measurements rather than of which list it came from.
|
||||
*
|
||||
* The texture is tiny (see `MARKER_TEXTURE_WIDTH`): a marker is a few pixels across, so what
|
||||
* survives is essentially its average colour, and generating it costs well under a millisecond.
|
||||
* How far, in AU, an orbit's drawn ellipse may be from its current one before it is drawn again:
|
||||
* well under the 128 chords' own sag from the true curve, at most 0.00055 AU for Mars and 0.0032
|
||||
* for Saturn, near aphelion, where points spaced evenly in true anomaly lie furthest apart.
|
||||
*/
|
||||
function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined, systemSpanAu: number, appearance: PlanetAppearance | undefined): THREE.Mesh {
|
||||
const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm, systemSpanAu), 16, 12);
|
||||
const material = appearance
|
||||
? new THREE.MeshBasicMaterial({ map: planetTexture(appearance, { width: MARKER_TEXTURE_WIDTH, height: MARKER_TEXTURE_HEIGHT }) })
|
||||
: new THREE.MeshBasicMaterial({ color: colorForKind(kind) });
|
||||
return new THREE.Mesh(geometry, material);
|
||||
const ORBIT_RESHAPE_AU = 1e-4;
|
||||
|
||||
/**
|
||||
* Draws an orbit line's ellipse again once the axis and eccentricity it was drawn with have drifted
|
||||
* from `elements`' by more than {@link ORBIT_RESHAPE_AU}. Standish's rates move Saturn's
|
||||
* 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 {
|
||||
id: string;
|
||||
kind: SystemMemberKind;
|
||||
elements: OrbitalElements;
|
||||
gmAu3PerDay2: number;
|
||||
rates: MeanElementRates;
|
||||
marker: THREE.Mesh;
|
||||
orbitLine: THREE.Line;
|
||||
/** Rotation from this body's own element frame into the scene's equatorial one. */
|
||||
frame: THREE.Quaternion;
|
||||
/** AU position last computed for this body; moons read their parent's here. */
|
||||
position: THREE.Vector3;
|
||||
/** Sidereal rotation, where the catalogue publishes one; negative is retrograde. */
|
||||
rotationPeriodHours?: number;
|
||||
rotationalElements?: RotationalElements;
|
||||
}
|
||||
|
||||
interface TrackedMoon {
|
||||
id: string;
|
||||
elements: OrbitalElements;
|
||||
gmAu3PerDay2: number;
|
||||
rates: MeanElementRates;
|
||||
marker: THREE.Mesh;
|
||||
orbitLine: THREE.Line;
|
||||
frame: THREE.Quaternion;
|
||||
pivot: THREE.Group;
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,18 +371,18 @@ export class SystemOrbitsRenderer {
|
||||
readonly members: readonly SystemMember[];
|
||||
/** Largest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
|
||||
readonly maxTopLevelSemiMajorAxisAu: number;
|
||||
/** Smallest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
|
||||
readonly minTopLevelSemiMajorAxisAu: number;
|
||||
/**
|
||||
* The plane this system is read against, as a rotation from XY into the scene's equatorial
|
||||
* frame: the ecliptic for the solar system, the plane of the sky for everything else.
|
||||
*/
|
||||
readonly referenceFrame: THREE.Quaternion;
|
||||
/**
|
||||
* Outer radius (AU) of the reference grid, or 0 where there is none. This — not the outermost
|
||||
* orbit — is the widest thing the system draws, so it is what the camera has to frame.
|
||||
* How far (AU) from the star the system draws anything, or 0 where it draws nothing: what the
|
||||
* 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 moons: TrackedMoon[] = [];
|
||||
@@ -185,6 +394,22 @@ export class SystemOrbitsRenderer {
|
||||
* following them each tick costs no allocation at all.
|
||||
*/
|
||||
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(
|
||||
bodies: readonly BodyRecord[],
|
||||
@@ -196,19 +421,19 @@ export class SystemOrbitsRenderer {
|
||||
* system is and therefore what it looks like. Omitted for a host that is not in the star
|
||||
* catalogue, leaving its bodies classified on size and density alone.
|
||||
*/
|
||||
hostLuminositySolar?: number | null
|
||||
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 topLevelBodiesById = new Map<string, BodyRecord>();
|
||||
|
||||
// Measured before anything is built, because marker sizes are scaled against the span and
|
||||
// the markers are created as the bodies are added.
|
||||
const topLevelAxes = [
|
||||
...bodies.filter((body) => !body.parentBodyId).map((body) => body.orbit.semiMajorAxisAu),
|
||||
...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map((exoplanet) => exoplanet.orbit.semiMajorAxisAu!)
|
||||
].filter((axis) => Number.isFinite(axis) && axis > 0);
|
||||
const topLevelOrbits = [
|
||||
...bodies.filter((body) => !body.parentBodyId).map(({ orbit }) => ({ axis: orbit.semiMajorAxisAu, eccentricity: orbit.eccentricity })),
|
||||
...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map(({ orbit }) => ({ axis: orbit.semiMajorAxisAu!, eccentricity: orbit.eccentricity ?? 0 }))
|
||||
].filter(({ axis }) => Number.isFinite(axis) && axis > 0);
|
||||
const topLevelAxes = topLevelOrbits.map(({ axis }) => axis);
|
||||
this.maxTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.max(...topLevelAxes) : 0;
|
||||
this.minTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.min(...topLevelAxes) : 0;
|
||||
|
||||
for (const body of bodies) {
|
||||
if (!body.parentBodyId) {
|
||||
@@ -222,7 +447,16 @@ export class SystemOrbitsRenderer {
|
||||
}
|
||||
// A body reaches here only when it has no parentBodyId, so `kind` is 'planet' or 'dwarf'.
|
||||
const kind: SystemMemberKind = body.kind;
|
||||
const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm, 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 });
|
||||
}
|
||||
|
||||
@@ -235,8 +469,8 @@ export class SystemOrbitsRenderer {
|
||||
if (!parentTracked) {
|
||||
continue; // orphaned moon reference; skip rather than crash.
|
||||
}
|
||||
const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar));
|
||||
members.push({ id: body.id, kind: 'moon', marker: moon.marker });
|
||||
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, parentId: parent.id });
|
||||
}
|
||||
|
||||
// Every exoplanet in a system shares the same line of sight, so the frame is built once.
|
||||
@@ -251,27 +485,28 @@ export class SystemOrbitsRenderer {
|
||||
continue;
|
||||
}
|
||||
const elements = resolveOrbitalElements(exoplanet.orbit);
|
||||
const radiusKm = exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined;
|
||||
// Not `gmForParent(undefined)`: that assumes a solar-mass host for every system, and
|
||||
// most exoplanet hosts are red dwarfs a fraction of the Sun's mass.
|
||||
const radiusEarth = exoplanet.radiusEarth ?? radiusFromMassEarth(exoplanet.massEarth);
|
||||
const radiusKm = radiusEarth ? radiusEarth * EARTH_RADIUS_KM : undefined;
|
||||
// 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({
|
||||
semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu,
|
||||
periodDays: exoplanet.periodDays,
|
||||
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 });
|
||||
}
|
||||
|
||||
this.members = members;
|
||||
|
||||
// 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.
|
||||
this.referenceFrame = bodies.some((body) => !body.parentBodyId) ? ECLIPTIC_FRAME.clone() : exoplanetFrame;
|
||||
|
||||
const rings = systemGridRingsAu(this.maxTopLevelSemiMajorAxisAu);
|
||||
this.gridOuterRadiusAu = rings.length > 0 ? rings[rings.length - 1] : 0;
|
||||
this.outermostRadiusAu = Math.max(rings.length > 0 ? rings[rings.length - 1] : 0, ...topLevelOrbits.map(({ axis, eccentricity }) => axis * (1 + eccentricity)));
|
||||
if (rings.length > 0) {
|
||||
this.grid = new PolarGridPlane({
|
||||
ringRadii: rings,
|
||||
@@ -294,14 +529,30 @@ export class SystemOrbitsRenderer {
|
||||
|
||||
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 {
|
||||
this.showNextPhotograph();
|
||||
const jdTdb = tdbFromUtc(epochJd);
|
||||
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.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) {
|
||||
@@ -310,8 +561,23 @@ export class SystemOrbitsRenderer {
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
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
|
||||
@@ -319,9 +585,24 @@ export class SystemOrbitsRenderer {
|
||||
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 {
|
||||
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. */
|
||||
@@ -329,11 +610,31 @@ export class SystemOrbitsRenderer {
|
||||
return this.members.map((member) => member.marker);
|
||||
}
|
||||
|
||||
/** Shows or hides the orbit lines and the reference grid, leaving the bodies themselves. */
|
||||
setLayerVisibility(layers: { orbits: boolean; grid: boolean }): void {
|
||||
this.object.traverse((child) => {
|
||||
if (child.name === ORBIT_LINE_NAME) {
|
||||
child.visible = layers.orbits;
|
||||
}
|
||||
});
|
||||
if (this.grid) {
|
||||
this.grid.object.visible = layers.grid;
|
||||
}
|
||||
if (this.tethers) {
|
||||
this.tethers.object.visible = layers.grid;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
clearTimeout(this.surfaceTimer);
|
||||
this.surfacesToPaint.length = 0;
|
||||
this.photographsToShow.length = 0;
|
||||
this.grid?.dispose();
|
||||
this.tethers?.dispose();
|
||||
for (const { geometry, material } of this.disposables) {
|
||||
if (geometry !== MARKER_SPHERE) {
|
||||
geometry.dispose();
|
||||
}
|
||||
material.dispose();
|
||||
}
|
||||
// Detach as well as dispose. A star-to-star hop builds a new renderer and drops the old
|
||||
@@ -348,18 +649,19 @@ export class SystemOrbitsRenderer {
|
||||
id: string,
|
||||
kind: SystemMemberKind,
|
||||
elements: OrbitalElements,
|
||||
gmAu3PerDay2: number,
|
||||
rates: MeanElementRates,
|
||||
radiusKm: number | undefined,
|
||||
frame: THREE.Quaternion,
|
||||
appearance?: PlanetAppearance
|
||||
appearance?: PlanetAppearance,
|
||||
rotation?: { periodHours?: number; elements?: RotationalElements }
|
||||
): TrackedTopLevelBody {
|
||||
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.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
||||
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
||||
|
||||
const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, 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);
|
||||
return tracked;
|
||||
}
|
||||
@@ -367,21 +669,36 @@ export class SystemOrbitsRenderer {
|
||||
private addMoon(
|
||||
id: string,
|
||||
elements: OrbitalElements,
|
||||
gmAu3PerDay2: number,
|
||||
rates: MeanElementRates,
|
||||
radiusKm: number | undefined,
|
||||
parent: TrackedTopLevelBody,
|
||||
frame: THREE.Quaternion,
|
||||
appearance?: PlanetAppearance
|
||||
appearance?: PlanetAppearance,
|
||||
rotation?: { periodHours?: number; elements?: RotationalElements },
|
||||
massRatio?: number
|
||||
): TrackedMoon {
|
||||
const pivot = new THREE.Group();
|
||||
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);
|
||||
this.object.add(pivot);
|
||||
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
||||
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
||||
|
||||
const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, 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);
|
||||
return moon;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||
import { BookmarksStore } from '../../shared/state/bookmarks.store';
|
||||
import { TimeStore } from '../../shared/state/time.store';
|
||||
import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent } from './hud-dock.component';
|
||||
|
||||
// jsdom has no matchMedia, which the dock reads once, at import: this one answers from `viewport`.
|
||||
const viewport = vi.hoisted(() => {
|
||||
const state = { wide: true };
|
||||
window.matchMedia = (() => ({ get matches() { return state.wide; } })) as unknown as typeof window.matchMedia;
|
||||
return state;
|
||||
});
|
||||
|
||||
class EmptyDataLoaderService {
|
||||
loadStars() {
|
||||
return Promise.resolve({ stars: [], positions: new Float32Array(0) });
|
||||
}
|
||||
loadBodies() {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
loadExoplanets() {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
||||
|
||||
describe('HudDockComponent', () => {
|
||||
let fixture: ComponentFixture<HudDockComponent>;
|
||||
|
||||
function host(): HTMLElement {
|
||||
return fixture.nativeElement as HTMLElement;
|
||||
}
|
||||
|
||||
function tabNames(): string[] {
|
||||
return [...host().querySelectorAll('[role="tab"]')].map((tab) => tab.textContent?.trim() ?? '');
|
||||
}
|
||||
|
||||
function tab(name: string): HTMLButtonElement {
|
||||
const found = [...host().querySelectorAll<HTMLButtonElement>('[role="tab"]')].find((t) => t.textContent?.trim() === name);
|
||||
if (!found) {
|
||||
throw new Error(`no "${name}" tab`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function setReadout(): void {
|
||||
fixture.componentRef.setInput('eyebrow', 'Galactic Scale');
|
||||
fixture.componentRef.setInput('title', 'Milky Way');
|
||||
fixture.componentRef.setInput('subtitle', 'Barred spiral');
|
||||
fixture.componentRef.setInput('readouts', [{ label: 'Arms', value: '5' }, { label: 'Luminosity', value: '1 L☉', derived: true }]);
|
||||
fixture.componentRef.setInput('note', 'Illustrative model.');
|
||||
fixture.componentRef.setInput('range', '21.5 kpc');
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HudDockComponent],
|
||||
providers: [
|
||||
{ provide: DataLoaderService, useClass: EmptyDataLoaderService },
|
||||
{ provide: Router, useValue: { navigate: vi.fn().mockResolvedValue(true) } }
|
||||
]
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(HudDockComponent);
|
||||
});
|
||||
|
||||
it('offers the search and what has been kept, when it has nothing else', () => {
|
||||
// Bookmarks are always offered: it is the only place that says the map can keep anything.
|
||||
fixture.detectChanges();
|
||||
expect(tabNames()).toEqual(['Search', 'Bookmarks']);
|
||||
expect(host().querySelector('[role="tabpanel"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('grows a tab per thing it has been given', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
|
||||
fixture.detectChanges();
|
||||
expect(tabNames()).toEqual(['Search', 'Readout', 'Bookmarks', 'Display']);
|
||||
|
||||
fixture.componentRef.setInput('routing', true);
|
||||
fixture.detectChanges();
|
||||
expect(tabNames()).toEqual(['Search', 'Readout', 'Routes', 'Bookmarks', 'Display']);
|
||||
});
|
||||
|
||||
it('offers the clock alone, as a Clock tab, to a surface with no layers', () => {
|
||||
fixture.componentRef.setInput('clock', true);
|
||||
fixture.componentRef.setInput('defaultTab', 'display');
|
||||
fixture.detectChanges();
|
||||
expect(tabNames()).toEqual(['Search', 'Bookmarks', 'Clock']);
|
||||
const panel = host().querySelector('#dock-panel-display')!;
|
||||
expect(panel.querySelector('[role="radiogroup"][aria-label="Clock rate"]')).not.toBeNull();
|
||||
expect(panel.querySelector('#clock-date')).not.toBeNull();
|
||||
expect(panel.textContent).not.toContain('Layers');
|
||||
});
|
||||
|
||||
it('opens the default tab on mount and renders the readout from its inputs', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(tab('Readout').getAttribute('aria-selected')).toBe('true');
|
||||
const text = host().textContent ?? '';
|
||||
for (const expected of ['Galactic Scale', 'Milky Way', 'Barred spiral', 'Arms', '5', 'Illustrative model.', 'Derived, not catalogued', '21.5 kpc']) {
|
||||
expect(text).toContain(expected);
|
||||
}
|
||||
expect(host().querySelector('[data-testid="hud-title"]')?.textContent?.trim()).toBe('Milky Way');
|
||||
});
|
||||
|
||||
it('leaves out the optional lines it was given nothing for', () => {
|
||||
fixture.componentRef.setInput('title', 'Local Stars');
|
||||
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('dl')).toBeNull();
|
||||
expect(host().textContent).not.toContain('undefined');
|
||||
});
|
||||
|
||||
it('keeps the range on the strip whichever panel is open, and hides it when there is none', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('defaultTab', 'search');
|
||||
fixture.detectChanges();
|
||||
expect(host().textContent).toContain('21.5 kpc');
|
||||
|
||||
fixture.componentRef.setInput('range', '');
|
||||
fixture.detectChanges();
|
||||
expect(host().textContent).not.toContain('Range');
|
||||
});
|
||||
|
||||
it('toggles a tab closed when it is clicked while open', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||
fixture.detectChanges();
|
||||
|
||||
tab('Readout').click();
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('[role="tabpanel"]')).toBeNull();
|
||||
expect(tab('Readout').getAttribute('aria-selected')).toBe('false');
|
||||
|
||||
tab('Search').click();
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('input[type="text"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('emits the flipped layer and nothing else', () => {
|
||||
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
|
||||
fixture.componentRef.setInput('defaultTab', 'display');
|
||||
fixture.detectChanges();
|
||||
const emitted: HudDisplay[] = [];
|
||||
fixture.componentInstance.displayChange.subscribe((display) => emitted.push(display));
|
||||
|
||||
const orbits = [...host().querySelectorAll<HTMLButtonElement>('[aria-pressed]')].find((b) => b.textContent?.includes('Orbits'));
|
||||
orbits?.click();
|
||||
|
||||
expect(emitted).toEqual([{ ...DEFAULT_HUD_DISPLAY, orbits: false }]);
|
||||
});
|
||||
|
||||
it('reflects the layer state it is given as pressed buttons', () => {
|
||||
fixture.componentRef.setInput('display', { ...DEFAULT_HUD_DISPLAY, grid: false });
|
||||
fixture.componentRef.setInput('defaultTab', 'display');
|
||||
fixture.detectChanges();
|
||||
const pressed = [...host().querySelectorAll('[aria-pressed]')].map((b) => `${b.textContent?.trim()}=${b.getAttribute('aria-pressed')}`);
|
||||
expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true', 'Jump links=false', 'Plan view=false', 'Backwards=false']);
|
||||
});
|
||||
|
||||
it('says how to keep a place, rather than showing an empty list', () => {
|
||||
fixture.componentRef.setInput('defaultTab', 'bookmarks');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(host().querySelector('#dock-panel-bookmarks ul')).toBeNull();
|
||||
expect(host().textContent).toContain('Nothing kept yet');
|
||||
});
|
||||
|
||||
it('lists what has been kept, newest first, and says which kind each is', () => {
|
||||
const bookmarks = TestBed.inject(BookmarksStore);
|
||||
bookmarks.toggle({ kind: 'star', id: 7, name: 'Sirius' });
|
||||
bookmarks.toggle({ kind: 'body', id: 'earth', name: 'Earth' });
|
||||
fixture.componentRef.setInput('defaultTab', 'bookmarks');
|
||||
fixture.detectChanges();
|
||||
|
||||
const rows = [...host().querySelectorAll('#dock-panel-bookmarks li')].map((li) => li.textContent?.replace(/\s+/g, ' ').trim());
|
||||
expect(rows[0]).toContain('Earth');
|
||||
expect(rows[0]).toContain('Body');
|
||||
expect(rows[1]).toContain('Sirius');
|
||||
expect(rows[1]).toContain('System');
|
||||
});
|
||||
|
||||
it('hands back the place that was chosen, whole', () => {
|
||||
const bookmarks = TestBed.inject(BookmarksStore);
|
||||
bookmarks.toggle({ kind: 'star', id: 7, name: 'Sirius' });
|
||||
fixture.componentRef.setInput('defaultTab', 'bookmarks');
|
||||
fixture.detectChanges();
|
||||
const chosen: unknown[] = [];
|
||||
fixture.componentInstance.bookmarkChosen.subscribe((bookmark) => chosen.push(bookmark));
|
||||
|
||||
host().querySelector<HTMLButtonElement>('#dock-panel-bookmarks li button')?.click();
|
||||
|
||||
expect(chosen).toEqual([{ kind: 'star', id: 7, name: 'Sirius' }]);
|
||||
});
|
||||
|
||||
it('forgets one without disturbing the rest', () => {
|
||||
const bookmarks = TestBed.inject(BookmarksStore);
|
||||
bookmarks.toggle({ kind: 'star', id: 7, name: 'Sirius' });
|
||||
bookmarks.toggle({ kind: 'body', id: 'earth', name: 'Earth' });
|
||||
fixture.componentRef.setInput('defaultTab', 'bookmarks');
|
||||
fixture.detectChanges();
|
||||
|
||||
host().querySelector<HTMLButtonElement>('[aria-label="Forget Earth"]')?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(bookmarks.bookmarks().map((bookmark) => bookmark.name)).toEqual(['Sirius']);
|
||||
expect(host().textContent).not.toContain('Earth');
|
||||
});
|
||||
|
||||
it('keeps the star the readout is about, and says so on the control', () => {
|
||||
const bookmarks = TestBed.inject(BookmarksStore);
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('title', 'Sirius');
|
||||
fixture.componentRef.setInput('keepableStarId', 7);
|
||||
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||
fixture.detectChanges();
|
||||
|
||||
const keep = host().querySelector<HTMLButtonElement>('[aria-label="Keep Sirius"]');
|
||||
expect(keep?.getAttribute('aria-pressed')).toBe('false');
|
||||
keep?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(bookmarks.has('star', 7)).toBe(true);
|
||||
expect(host().querySelector('[aria-label="Forget Sirius"]')?.getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('keeps the one system whose catalogue id is zero, which truthiness would have lost', () => {
|
||||
// The Sun is star 0. A `@if (id; as ...)` reads that as "no star" and hides the control.
|
||||
const bookmarks = TestBed.inject(BookmarksStore);
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('title', 'Sol');
|
||||
fixture.componentRef.setInput('keepableStarId', 0);
|
||||
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||
fixture.detectChanges();
|
||||
|
||||
host().querySelector<HTMLButtonElement>('[aria-label="Keep Sol"]')?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(bookmarks.has('star', 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('offers nothing to keep where the readout is a scale rather than a place', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(host().querySelector('[aria-label^="Keep"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the search on "/" from anywhere but a text field', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||
fixture.detectChanges();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true }));
|
||||
fixture.detectChanges();
|
||||
expect(tab('Search').getAttribute('aria-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('hands the panel back to the readout once a result is picked', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('defaultTab', 'search');
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance.onPicked();
|
||||
fixture.detectChanges();
|
||||
expect(tab('Readout').getAttribute('aria-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('will not plot from a departure that was typed but never chosen', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('routing', true);
|
||||
fixture.componentRef.setInput('currentStar', { id: 3, name: "Barnard's Star", subtitle: '1.8 pc' });
|
||||
fixture.componentRef.setInput('routeOptions', [{ id: 7, name: 'Sirius', subtitle: '2.6 pc' }]);
|
||||
fixture.componentRef.setInput('defaultTab', 'routes');
|
||||
fixture.detectChanges();
|
||||
const plot = () => [...host().querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.includes('Plot route'))!;
|
||||
const type = (field: string, value: string) => {
|
||||
const input = host().querySelector<HTMLInputElement>(`#route-${field}`)!;
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
type('to', 'Sir');
|
||||
host().querySelector<HTMLButtonElement>('#dock-panel-routes ul button')!.click();
|
||||
fixture.detectChanges();
|
||||
// With the departure field empty, the view's own star stands in for it.
|
||||
expect(plot().disabled).toBe(false);
|
||||
|
||||
// Text that names no chosen star is not a departure: plotting from the view's star instead
|
||||
// would name one place and leave from another.
|
||||
type('from', 'Sol');
|
||||
expect(plot().disabled).toBe(true);
|
||||
|
||||
type('from', '');
|
||||
expect(plot().disabled).toBe(false);
|
||||
|
||||
// A space is not text that names a star: the field looks empty, the scene offers nothing to
|
||||
// choose for it, and the button going dead would have nothing on screen to explain it.
|
||||
type('from', ' ');
|
||||
expect(plot().disabled).toBe(false);
|
||||
|
||||
// The offer beside a refusal is the same request by another route, so it is held to the same
|
||||
// test: moving the range with nothing to plot leaves the panel contradicting itself.
|
||||
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: 1.8, gaveUp: false, least: true });
|
||||
fixture.detectChanges();
|
||||
const offer = () => host().querySelector<HTMLButtonElement>('[data-testid="route-summary"] button')!;
|
||||
expect(offer().disabled).toBe(false);
|
||||
|
||||
type('from', 'Sol');
|
||||
expect(offer().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('does not replay the acquire wipe over the Routes panel, whose entries survive the trip', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('routing', true);
|
||||
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
|
||||
fixture.componentRef.setInput('defaultTab', 'routes');
|
||||
fixture.detectChanges();
|
||||
|
||||
// The wipe clips its panel for 380 ms, which swallows clicks on entries that are already there.
|
||||
expect(host().querySelector('#dock-panel-routes')?.classList.contains('hud-acquire')).toBe(false);
|
||||
tab('Display').click();
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('#dock-panel-display')?.classList.contains('hud-acquire')).toBe(true);
|
||||
});
|
||||
|
||||
it('says the search gave up rather than that there is no route, when that is what happened', () => {
|
||||
fixture.componentRef.setInput('routing', true);
|
||||
fixture.componentRef.setInput('defaultTab', 'routes');
|
||||
const summary = () => host().querySelector('[data-testid="route-summary"]')?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
|
||||
|
||||
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: true, least: false });
|
||||
fixture.detectChanges();
|
||||
expect(summary()).toBe('Too many stars to search at this range.');
|
||||
|
||||
// Having looked everywhere the range reaches is a different answer, and one that can be stated.
|
||||
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: false, least: true });
|
||||
fixture.detectChanges();
|
||||
expect(summary()).toContain('No chain of jumps up to');
|
||||
|
||||
// A range a chain was found at is worth offering — but the search that gave up at the range
|
||||
// asked for still gave up, and saying "no route" beside the offer is saying it did not.
|
||||
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: 6.4, gaveUp: true, least: false });
|
||||
fixture.detectChanges();
|
||||
expect(summary()).toBe('Too many stars to search at this range. 6.40 pc would reach.');
|
||||
|
||||
// The other way round: the range asked for was searched to exhaustion and the wider search was
|
||||
// the one that gave up. There is no route at this range, and nothing further can be claimed.
|
||||
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: false, least: false });
|
||||
fixture.detectChanges();
|
||||
expect(summary()).toBe('No route at this range.');
|
||||
});
|
||||
|
||||
it('keeps what the Routes panel was set to across a trip to another tab', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('routing', true);
|
||||
fixture.componentRef.setInput('routeOptions', [{ id: 7, name: 'Sirius', subtitle: '2.6 pc' }]);
|
||||
fixture.componentRef.setInput('defaultTab', 'routes');
|
||||
fixture.detectChanges();
|
||||
|
||||
const destination = host().querySelector<HTMLInputElement>('#route-to')!;
|
||||
destination.value = 'Sir';
|
||||
destination.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
host().querySelector<HTMLButtonElement>('#dock-panel-routes ul button')!.click();
|
||||
const range = host().querySelector<HTMLInputElement>('#route-range')!;
|
||||
range.value = '6';
|
||||
range.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
|
||||
tab('Readout').click();
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector<HTMLElement>('#dock-panel-routes')!.hidden).toBe(true);
|
||||
tab('Routes').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(host().querySelector<HTMLElement>('#dock-panel-routes')!.hidden).toBe(false);
|
||||
expect(host().querySelector<HTMLInputElement>('#route-to')!.value).toBe('Sirius');
|
||||
expect(host().querySelector<HTMLInputElement>('#route-range')!.value).toBe('6');
|
||||
});
|
||||
|
||||
describe('clock', () => {
|
||||
let time: TimeStore;
|
||||
|
||||
beforeEach(() => {
|
||||
time = TestBed.inject(TimeStore);
|
||||
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
|
||||
fixture.componentRef.setInput('defaultTab', 'display');
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
function button(name: string): HTMLButtonElement {
|
||||
return [...host().querySelectorAll<HTMLButtonElement>('#dock-panel-display button')].find((b) => b.textContent?.trim() === name)!;
|
||||
}
|
||||
|
||||
function radio(name: string): HTMLInputElement {
|
||||
return [...host().querySelectorAll('#dock-panel-display label')].find((l) => l.textContent?.trim() === name)!.querySelector('input')!;
|
||||
}
|
||||
|
||||
it('runs the same rates backwards, and keeps the direction when the rate changes', () => {
|
||||
button('Backwards').click();
|
||||
fixture.detectChanges();
|
||||
expect(time.rate()).toBe(-1);
|
||||
expect(button('Backwards').getAttribute('aria-pressed')).toBe('true');
|
||||
// Still real time: the direction is not a fifth rate.
|
||||
expect(radio('Real time').checked).toBe(true);
|
||||
|
||||
radio('1 d/s').click();
|
||||
fixture.detectChanges();
|
||||
expect(time.rate()).toBe(-86_400);
|
||||
expect(radio('1 d/s').checked).toBe(true);
|
||||
|
||||
button('Backwards').click();
|
||||
fixture.detectChanges();
|
||||
expect(time.rate()).toBe(86_400);
|
||||
expect(button('Backwards').getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('opens the date field on the clock’s date, named and held to the window the elements hold for', () => {
|
||||
const field = host().querySelector<HTMLInputElement>('#clock-date')!;
|
||||
|
||||
expect(field.type).toBe('datetime-local');
|
||||
expect(field.value).toBe(time.date().toISOString().slice(0, 16));
|
||||
expect(host().querySelector('label[for="clock-date"]')?.textContent?.trim()).toBe('Date (UTC)');
|
||||
expect(field.min).toBe('0001-01-01T00:00');
|
||||
expect(field.max).toBe('3000-01-01T00:00');
|
||||
expect(host().querySelector(`#${field.getAttribute('aria-describedby')}`)?.textContent).toContain('AD 1 to AD 3000');
|
||||
});
|
||||
|
||||
it('jumps the clock to the date submitted, read as UTC', () => {
|
||||
// Five and a half hours from UTC, so a field read as local time lands elsewhere: in UTC itself,
|
||||
// where CI runs, the two readings are the same instant and this could not tell them apart.
|
||||
vi.stubEnv('TZ', 'Asia/Kolkata');
|
||||
const field = host().querySelector<HTMLInputElement>('#clock-date')!;
|
||||
field.value = '2020-12-21T18:00';
|
||||
button('Go').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(time.date().toISOString().slice(0, 16)).toBe('2020-12-21T18:00');
|
||||
expect(time.atNow()).toBe(false);
|
||||
|
||||
// Back to now puts the field back on the present too, not on the date left behind.
|
||||
button('Back to now').click();
|
||||
fixture.detectChanges();
|
||||
expect(time.atNow()).toBe(true);
|
||||
expect(host().querySelector<HTMLInputElement>('#clock-date')!.value).toBe(time.date().toISOString().slice(0, 16));
|
||||
});
|
||||
|
||||
it('folds the sheet away on a phone once a date is set, so the system it covered can be seen', () => {
|
||||
viewport.wide = false;
|
||||
try {
|
||||
host().querySelector<HTMLInputElement>('#clock-date')!.value = '2020-12-21T18:00';
|
||||
button('Go').click();
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('#dock-panel-display')).toBeNull();
|
||||
} finally {
|
||||
viewport.wide = true;
|
||||
}
|
||||
});
|
||||
|
||||
it('hands focus to the tab that folded, not to the page, so Enter opens the panel again', () => {
|
||||
viewport.wide = false;
|
||||
try {
|
||||
const field = host().querySelector<HTMLInputElement>('#clock-date')!;
|
||||
field.focus();
|
||||
field.value = '2020-12-21T18:00';
|
||||
button('Go').click();
|
||||
fixture.detectChanges();
|
||||
expect(document.activeElement?.id).toBe('dock-tab-display');
|
||||
} finally {
|
||||
viewport.wide = true;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the date strip on screen on a phone: the tabs give way to it, and scroll', () => {
|
||||
// At 360 px the system view's five tabs take 397 px, and pushed the strip past the right
|
||||
// edge, where nothing scrolls: after Go on a phone the date was nowhere on screen.
|
||||
fixture.componentRef.setInput('date', '2020-12-21');
|
||||
fixture.componentRef.setInput('range', '417 AU');
|
||||
fixture.detectChanges();
|
||||
const tabs = host().querySelector('[role="tablist"]')!.classList;
|
||||
expect(tabs.contains('min-w-0') && tabs.contains('overflow-x-auto')).toBe(true);
|
||||
// Its own scrollbar, where the browser draws one, thin and dark: a desktop's default was a
|
||||
// light bar 15 px tall across the dock.
|
||||
expect(tabs.contains('scheme-dark') && tabs.contains('[scrollbar-width:thin]')).toBe(true);
|
||||
expect(host().querySelector('[data-testid="hud-date"]')!.classList.contains('shrink-0')).toBe(true);
|
||||
// The range keeps its width where it is shown, or '417 AU' wraps and the row grows 20 px; on a
|
||||
// phone it is not shown, where at the present it took the Display tab out of sight.
|
||||
const range = [...host().querySelectorAll('p')].find((p) => p.textContent?.includes('Range'))!.classList;
|
||||
expect(range.contains('shrink-0') && range.contains('max-sm:hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('brings the tab that matters back into view once the date strip has narrowed the tabs', () => {
|
||||
// jsdom lays nothing out; what is checked is which tab is asked to be in view, and when.
|
||||
const scrolled: string[] = [];
|
||||
HTMLElement.prototype.scrollIntoView = function (this: HTMLElement) {
|
||||
scrolled.push(this.id);
|
||||
};
|
||||
try {
|
||||
// On a phone: the tab focus went back to, which after Go sat wholly out of sight.
|
||||
viewport.wide = false;
|
||||
host().querySelector<HTMLInputElement>('#clock-date')!.value = '2020-12-21T18:00';
|
||||
button('Go').click();
|
||||
fixture.detectChanges();
|
||||
scrolled.length = 0;
|
||||
fixture.componentRef.setInput('date', '2020-12-21');
|
||||
fixture.detectChanges();
|
||||
expect(scrolled).toEqual(['dock-tab-display']);
|
||||
// On a wider window, where the panel stays open: its tab, focus being in the panel.
|
||||
viewport.wide = true;
|
||||
fixture.componentRef.setInput('date', '');
|
||||
fixture.detectChanges();
|
||||
tab('Display').click();
|
||||
fixture.detectChanges();
|
||||
host().querySelector<HTMLInputElement>('#clock-date')!.focus();
|
||||
scrolled.length = 0;
|
||||
fixture.componentRef.setInput('date', '2020-12-21');
|
||||
fixture.detectChanges();
|
||||
expect(scrolled).toEqual(['dock-tab-display']);
|
||||
} finally {
|
||||
viewport.wide = true;
|
||||
delete (HTMLElement.prototype as Partial<HTMLElement>).scrollIntoView;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the panel open on a wide screen, where it covers little of the scene', () => {
|
||||
host().querySelector<HTMLInputElement>('#clock-date')!.value = '2020-12-21T18:00';
|
||||
button('Go').click();
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('#dock-panel-display')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('fills the date field again with the clock’s date when the panel is opened again', () => {
|
||||
time.setDate(new Date('2020-12-21T18:00Z'));
|
||||
fixture.componentInstance.toggleTab('display');
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.toggleTab('display');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(host().querySelector<HTMLInputElement>('#clock-date')!.value).toBe('2020-12-21T18:00');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,659 @@
|
||||
import {
|
||||
afterRenderEffect,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
input,
|
||||
OnInit,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
|
||||
import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store';
|
||||
import { CLOCK_WINDOW, TIME_RATES, TimeStore } from '../../shared/state/time.store';
|
||||
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
|
||||
import { SearchComponent } from '../search/search.component';
|
||||
import {
|
||||
RouteRequest,
|
||||
RouteResult,
|
||||
RoutesPanelComponent,
|
||||
RouteStarOption,
|
||||
} from './routes-panel.component';
|
||||
|
||||
export interface HudReadout {
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
/**
|
||||
* True when the figure was computed from other measurements rather than catalogued directly.
|
||||
* Marked in the panel and explained in its footnote, so a reasoned number is never mistaken for
|
||||
* an observed one.
|
||||
*/
|
||||
readonly derived?: boolean;
|
||||
}
|
||||
|
||||
/** Which layers the scene draws. Every one is a real object in the scene, toggled by visibility. */
|
||||
export interface HudDisplay {
|
||||
readonly labels: boolean;
|
||||
readonly orbits: boolean;
|
||||
readonly grid: boolean;
|
||||
readonly deepSky: boolean;
|
||||
readonly sky: boolean;
|
||||
/** The rings on stars known to host planets. */
|
||||
readonly systems: boolean;
|
||||
/** The graph of crossings within the range the Routes panel is set to. */
|
||||
readonly jumpLinks: boolean;
|
||||
/** Drawn flat: an orthographic projection, looking down on the plane. */
|
||||
readonly plan: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_HUD_DISPLAY: HudDisplay = {
|
||||
labels: true,
|
||||
orbits: true,
|
||||
grid: true,
|
||||
deepSky: true,
|
||||
sky: true,
|
||||
systems: true,
|
||||
jumpLinks: false,
|
||||
plan: false,
|
||||
};
|
||||
|
||||
const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
||||
{ key: 'labels', label: 'Labels' },
|
||||
{ key: 'orbits', label: 'Orbits' },
|
||||
{ key: 'grid', label: 'Grid' },
|
||||
{ key: 'deepSky', label: 'Deep sky' },
|
||||
{ key: 'sky', label: 'Sky' },
|
||||
{ key: 'systems', label: 'Systems' },
|
||||
{ key: 'jumpLinks', label: 'Jump links' },
|
||||
{ key: 'plan', label: 'Plan view' },
|
||||
];
|
||||
|
||||
export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | 'display';
|
||||
|
||||
const TAB_LABELS: Record<DockTab, string> = {
|
||||
search: 'Search',
|
||||
readout: 'Readout',
|
||||
routes: 'Routes',
|
||||
bookmarks: 'Bookmarks',
|
||||
display: 'Display',
|
||||
};
|
||||
|
||||
/** Tailwind's `sm` breakpoint: below it the dock is a bare tab strip and its panel is a sheet. */
|
||||
const WIDE_VIEWPORT = '(min-width: 640px)';
|
||||
/** One live query, read on every pointer-down, rather than a new MediaQueryList per read. */
|
||||
const wideViewportQuery =
|
||||
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia(WIDE_VIEWPORT)
|
||||
: null;
|
||||
|
||||
function isWideViewport(): boolean {
|
||||
return wideViewportQuery?.matches ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The instrument's dock: one rail across the bottom of the viewport carrying every tool and
|
||||
* readout, so the top of the screen keeps only the scale ladder and the nameplate.
|
||||
*
|
||||
* The tab strip is pinned to the bottom edge and never moves; whichever panel is open grows
|
||||
* upward from it. That is why the search field sits at the *bottom* of its panel with the
|
||||
* results above — the thing being typed into stays put while the list grows.
|
||||
*
|
||||
* `null` for the active tab is a real state, not an error: the strip alone. It is the default
|
||||
* below `sm`, where the panel would cover most of the scene, and wherever there is no readout
|
||||
* to show by default. Purely presentational: readouts and layer state arrive as inputs, the
|
||||
* only things it emits are layer toggles.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-hud-dock',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [BookmarkIconComponent, RoutesPanelComponent, SearchComponent],
|
||||
host: {
|
||||
class:
|
||||
'pointer-events-none fixed inset-x-2 bottom-2 z-20 block font-body sm:inset-x-6 sm:bottom-6',
|
||||
},
|
||||
template: `
|
||||
<!-- The column is transparent to the pointer and each surface in it opts back in: it is as
|
||||
wide as the strip and as tall as the open panel, so a solid one would swallow every
|
||||
click in the empty band beside the panel — where the scene, and its labels, are. -->
|
||||
<div class="flex flex-col items-start">
|
||||
@if (activeTab(); as tab) {
|
||||
<!-- Switching tabs remounts the panel and replays its acquire wipe: a new readout
|
||||
locking on, once per switch, never per keystroke. -->
|
||||
@switch (tab) {
|
||||
@case ('search') {
|
||||
<section
|
||||
id="dock-panel-search"
|
||||
role="tabpanel"
|
||||
aria-labelledby="dock-tab-search"
|
||||
class="hud-acquire pointer-events-auto mb-2 w-full max-w-xl"
|
||||
>
|
||||
<app-search (picked)="onPicked()" />
|
||||
</section>
|
||||
}
|
||||
@case ('readout') {
|
||||
<section
|
||||
id="dock-panel-readout"
|
||||
role="tabpanel"
|
||||
aria-labelledby="dock-tab-readout"
|
||||
class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3"
|
||||
>
|
||||
<p class="type-label text-muted">{{ eyebrow() }}</p>
|
||||
<div class="mt-1 flex items-start gap-2">
|
||||
<p
|
||||
data-testid="hud-title"
|
||||
class="min-w-0 flex-1 text-lg font-bold tracking-[0.04em] text-text uppercase"
|
||||
>
|
||||
{{ title() }}
|
||||
</p>
|
||||
<!-- Against null, not against falsiness: the Sun's catalogue id is 0, and a
|
||||
truthiness test is what would quietly make the Solar System the one
|
||||
system nobody could keep. -->
|
||||
@if (keepableStarId() !== null) {
|
||||
<button
|
||||
type="button"
|
||||
[attr.aria-label]="
|
||||
(bookmarks.has('star', keepableStarId()!) ? 'Forget ' : 'Keep ') + title()
|
||||
"
|
||||
[attr.aria-pressed]="bookmarks.has('star', keepableStarId()!)"
|
||||
(click)="
|
||||
bookmarks.toggle({ kind: 'star', id: keepableStarId()!, name: title() })
|
||||
"
|
||||
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
[class]="
|
||||
bookmarks.has('star', keepableStarId()!)
|
||||
? 'text-accent'
|
||||
: 'text-muted hover:text-accent'
|
||||
"
|
||||
>
|
||||
<app-bookmark-icon
|
||||
class="h-3.5 w-3.5"
|
||||
[kept]="bookmarks.has('star', keepableStarId()!)"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (subtitle()) {
|
||||
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
|
||||
}
|
||||
@if (readouts().length) {
|
||||
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
||||
@for (readout of readouts(); track readout.label) {
|
||||
<div>
|
||||
<dt class="type-label text-muted">
|
||||
{{ readout.label }}
|
||||
@if (readout.derived) {
|
||||
<span class="text-accent/80" aria-hidden="true">*</span>
|
||||
}
|
||||
</dt>
|
||||
<dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
@if (note() || hasDerived()) {
|
||||
<p
|
||||
class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted"
|
||||
>
|
||||
@if (hasDerived()) {
|
||||
<span class="text-accent/80">*</span> Derived, not catalogued.
|
||||
}
|
||||
{{ note() }}
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@case ('bookmarks') {
|
||||
<section
|
||||
id="dock-panel-bookmarks"
|
||||
role="tabpanel"
|
||||
aria-labelledby="dock-tab-bookmarks"
|
||||
class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg"
|
||||
>
|
||||
@if (bookmarks.bookmarks().length) {
|
||||
<ul class="max-h-64 divide-y divide-border/25 overflow-y-auto">
|
||||
@for (
|
||||
bookmark of bookmarks.bookmarks();
|
||||
track bookmark.kind + ':' + bookmark.id
|
||||
) {
|
||||
<li class="flex items-stretch">
|
||||
<button
|
||||
type="button"
|
||||
(click)="onBookmarkChosen(bookmark)"
|
||||
class="flex min-w-0 flex-1 items-baseline gap-3 px-3 py-2 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-text">{{
|
||||
bookmark.name
|
||||
}}</span>
|
||||
<span class="type-label shrink-0 text-muted">{{
|
||||
bookmark.kind === 'star' ? 'System' : 'Body'
|
||||
}}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[attr.aria-label]="'Forget ' + bookmark.name"
|
||||
(click)="bookmarks.remove(bookmark.kind, bookmark.id)"
|
||||
class="shrink-0 border-l border-border/25 px-3 text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3 3l8 8M11 3l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="px-3 py-3 text-sm text-muted">
|
||||
Nothing kept yet. The
|
||||
<app-bookmark-icon class="inline-block h-3.5 w-3.5 -mb-0.5 text-accent" /> on a
|
||||
readout or a body keeps it here, in this browser.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@case ('display') {
|
||||
<section
|
||||
id="dock-panel-display"
|
||||
role="tabpanel"
|
||||
aria-labelledby="dock-tab-display"
|
||||
class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3"
|
||||
>
|
||||
@if (display()) {
|
||||
<p class="type-label text-muted">Layers</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@for (layer of layers; track layer.key) {
|
||||
<button
|
||||
type="button"
|
||||
[attr.aria-pressed]="isOn(layer.key)"
|
||||
(click)="toggleLayer(layer.key)"
|
||||
class="type-label flex items-center gap-2 border px-3 py-1.5 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
[class]="
|
||||
isOn(layer.key)
|
||||
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
||||
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
||||
"
|
||||
>
|
||||
<!-- The state mark: a filled tick when the layer is drawn, hollow when it is not. -->
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-1.5 w-1.5 border border-current"
|
||||
[class.bg-current]="isOn(layer.key)"
|
||||
></span>
|
||||
{{ layer.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- The clock. Orbits and rotations are both functions of a date, so this is the
|
||||
difference between a still picture and an orrery. -->
|
||||
<p class="type-label text-muted" [class.mt-4]="display()">Clock</p>
|
||||
<!-- Radios rather than buttons: the rates are one-of-four, and the native control
|
||||
carries that to a screen reader and to the arrow keys without any script. -->
|
||||
<div
|
||||
class="mt-2 flex flex-wrap items-center gap-2"
|
||||
role="radiogroup"
|
||||
aria-label="Clock rate"
|
||||
>
|
||||
<!-- A rate is picked by its size and the toggle after the radios says which way it
|
||||
runs, so a month a second backwards is the same radio as forwards. -->
|
||||
@for (rate of timeRates; track rate.secondsPerSecond) {
|
||||
<label
|
||||
class="type-label cursor-pointer border px-3 py-1.5 transition-colors has-[:focus-visible]:outline has-[:focus-visible]:outline-1 has-[:focus-visible]:-outline-offset-1 has-[:focus-visible]:outline-accent"
|
||||
[class]="
|
||||
Math.abs(time.rate()) === rate.secondsPerSecond
|
||||
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
||||
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
||||
"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="clock-rate"
|
||||
class="sr-only"
|
||||
[value]="rate.secondsPerSecond"
|
||||
[checked]="Math.abs(time.rate()) === rate.secondsPerSecond"
|
||||
(change)="time.setRate(Math.sign(time.rate()) * rate.secondsPerSecond)"
|
||||
/>
|
||||
{{ rate.label }}
|
||||
</label>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
[attr.aria-pressed]="time.rate() < 0"
|
||||
(click)="time.setRate(-time.rate())"
|
||||
class="type-label flex items-center gap-2 border px-3 py-1.5 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
[class]="
|
||||
time.rate() < 0
|
||||
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
||||
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
||||
"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-1.5 w-1.5 border border-current"
|
||||
[class.bg-current]="time.rate() < 0"
|
||||
></span>
|
||||
Backwards
|
||||
</button>
|
||||
@if (!time.atNow()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="backToNow()"
|
||||
class="type-label border border-border/60 px-3 py-1.5 text-muted transition-colors hover:border-accent/70 hover:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
>
|
||||
Back to now
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<!-- A form, so Enter in the field goes there and the browser holds the field to its
|
||||
min and max before anything is submitted. Submitted rather than applied on each
|
||||
change: Chrome reports a year typed digit by digit as 0002, 0020, 0202 and 2020,
|
||||
and the sky would jump through every one. -->
|
||||
<form class="mt-2 flex flex-wrap items-center gap-2" (submit)="goToDate($event)">
|
||||
<label for="clock-date" class="type-label text-muted">Date (UTC)</label>
|
||||
<input
|
||||
id="clock-date"
|
||||
name="date"
|
||||
type="datetime-local"
|
||||
required
|
||||
[min]="clockWindow.min"
|
||||
[max]="clockWindow.max"
|
||||
[value]="dateField()"
|
||||
aria-describedby="clock-date-window"
|
||||
class="hud-surface min-w-0 flex-1 px-2.5 py-1 text-sm text-text tabular-nums caret-accent scheme-dark focus:border-accent focus:outline-none sm:flex-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="type-label border border-border/60 px-3 py-1.5 text-muted transition-colors hover:border-accent/70 hover:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
>
|
||||
Go
|
||||
</button>
|
||||
<!-- One line: on a phone the panel is a sheet over the system it sets the date of, and
|
||||
each card already says how far its own orbit strays. -->
|
||||
<p id="clock-date-window" class="w-full text-[10px] text-muted">
|
||||
AD 1 to AD 3000, where the planets’ elements hold.
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
<!-- Hidden rather than unmounted: the departure, destination and range it holds would
|
||||
otherwise reset on every trip to another tab, while the scene kept drawing the graph at
|
||||
the old range. No acquire wipe, unlike the panels around it: this is the one that comes
|
||||
back with what it had, so it is not acquiring anything — and for the 380 ms the wipe
|
||||
runs, its clip path swallows clicks on the suggestions it just brought back. -->
|
||||
@if (routing()) {
|
||||
<section
|
||||
id="dock-panel-routes"
|
||||
role="tabpanel"
|
||||
aria-labelledby="dock-tab-routes"
|
||||
[hidden]="activeTab() !== 'routes'"
|
||||
class="hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-xl px-4 py-3"
|
||||
>
|
||||
<app-routes-panel
|
||||
[result]="routeResult()"
|
||||
[pending]="routePending()"
|
||||
[options]="routeOptions()"
|
||||
[currentStar]="currentStar()"
|
||||
(queryChange)="routeQuery.emit($event)"
|
||||
(routeRequested)="routeRequested.emit($event)"
|
||||
(starSelected)="onRouteStarSelected($event)"
|
||||
(rangeChange)="jumpRangeChange.emit($event)"
|
||||
/>
|
||||
</section>
|
||||
}
|
||||
|
||||
<div class="hud-brackets hud-surface pointer-events-auto flex w-full items-stretch">
|
||||
<!-- The tabs give way to the date and the range, and scroll: the five of the system view
|
||||
take 397 px, and on a portrait phone they pushed the date off the right edge. Where
|
||||
the browser draws a scrollbar of its own, it is a thin dark one: on a desktop window
|
||||
under 770 px wide its default was a light bar 15 px tall across the dark dock. -->
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Dock"
|
||||
class="flex min-w-0 scheme-dark items-stretch divide-x divide-border/40 overflow-x-auto [scrollbar-width:thin]"
|
||||
>
|
||||
@for (tab of tabs(); track tab) {
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
[id]="'dock-tab-' + tab"
|
||||
[attr.aria-selected]="activeTab() === tab"
|
||||
[attr.aria-controls]="activeTab() === tab ? 'dock-panel-' + tab : null"
|
||||
(click)="toggleTab(tab)"
|
||||
class="type-eyebrow px-3 py-2 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent sm:px-4"
|
||||
[class]="
|
||||
activeTab() === tab
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-muted hover:bg-accent/8 hover:text-accent'
|
||||
"
|
||||
>
|
||||
{{ tabLabel(tab) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<!-- Only while the map is away from the present: at the present the date is
|
||||
today's, which the reader's own machine already says. -->
|
||||
@if (date()) {
|
||||
<p
|
||||
class="ml-auto flex shrink-0 items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4"
|
||||
data-testid="hud-date"
|
||||
>
|
||||
<span class="type-label text-muted">Date</span>
|
||||
<span class="text-sm text-accent tabular-nums">{{ date() }}</span>
|
||||
</p>
|
||||
}
|
||||
<!-- Not on a phone, where it never showed before the tabs gave way to it: kept, its 121 px
|
||||
took the Display tab out of sight at the present, the state the map opens in. -->
|
||||
@if (range()) {
|
||||
<p
|
||||
class="flex shrink-0 items-baseline gap-2 border-l border-border/40 px-3 py-2 max-sm:hidden sm:px-4"
|
||||
[class.ml-auto]="!date()"
|
||||
>
|
||||
<span class="type-label text-muted">Range</span>
|
||||
<span class="text-sm text-accent tabular-nums">{{ range() }}</span>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class HudDockComponent implements OnInit {
|
||||
/** Readout panel contents. An empty title means there is nothing to read out, and no tab for it. */
|
||||
readonly eyebrow = input('');
|
||||
readonly title = input('');
|
||||
readonly subtitle = input('');
|
||||
readonly readouts = input<readonly HudReadout[]>([]);
|
||||
/** Standing caveat for the current view, e.g. that galactic structure is a model. */
|
||||
readonly note = input('');
|
||||
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
|
||||
readonly range = input('');
|
||||
/** The date the sky is drawn for; empty while the map is drawn for the present. */
|
||||
readonly date = input('');
|
||||
/**
|
||||
* Layer state; `null` means the surface has no layers to toggle, and no Display tab unless it
|
||||
* has the clock.
|
||||
*/
|
||||
readonly display = input<HudDisplay | null>(null);
|
||||
/** The clock without the layers, for a surface that is drawn at its date: the tab is then "Clock". */
|
||||
readonly clock = input(false);
|
||||
/** Which panel is open on a wide viewport when the dock mounts. */
|
||||
readonly defaultTab = input<DockTab | null>(null);
|
||||
/** Routing: what the scene found, what it offers for the fields, and where the view is. */
|
||||
readonly routeResult = input<RouteResult | null>(null);
|
||||
readonly routePending = input(false);
|
||||
readonly routeOptions = input<readonly RouteStarOption[]>([]);
|
||||
readonly currentStar = input<RouteStarOption | null>(null);
|
||||
/** The star the readout is about, where there is one to keep — a scale is not a place. */
|
||||
readonly keepableStarId = input<number | null>(null);
|
||||
/** Present makes the Routes tab available; absent means this surface cannot route. */
|
||||
readonly routing = input(false);
|
||||
|
||||
readonly displayChange = output<HudDisplay>();
|
||||
readonly routeQuery = output<string>();
|
||||
readonly routeRequested = output<RouteRequest>();
|
||||
readonly routeStarSelected = output<number>();
|
||||
readonly jumpRangeChange = output<number>();
|
||||
readonly bookmarkChosen = output<Bookmark>();
|
||||
|
||||
readonly layers = DISPLAY_LAYERS;
|
||||
readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived));
|
||||
readonly tabs = computed<readonly DockTab[]>(() => [
|
||||
'search',
|
||||
...(this.title() ? (['readout'] as const) : []),
|
||||
...(this.routing() ? (['routes'] as const) : []),
|
||||
// Always offered, even with nothing in it: it is the only place that says the map can keep
|
||||
// anything at all, and a tab that appears once you already know is a tab that never taught.
|
||||
'bookmarks',
|
||||
...(this.display() || this.clock() ? (['display'] as const) : []),
|
||||
]);
|
||||
|
||||
readonly activeTab = signal<DockTab | null>(null);
|
||||
|
||||
readonly bookmarks = inject(BookmarksStore);
|
||||
readonly time = inject(TimeStore);
|
||||
readonly timeRates = TIME_RATES;
|
||||
readonly clockWindow = CLOCK_WINDOW;
|
||||
protected readonly Math = Math;
|
||||
/**
|
||||
* What the date field holds when the panel opens: the clock's date at that moment. Not bound to
|
||||
* the running clock, which would rewrite the field under the reader's typing on every render.
|
||||
*/
|
||||
readonly dateField = signal('');
|
||||
|
||||
private readonly search = viewChild(SearchComponent);
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
private readonly dateShown = computed(() => this.date() !== '');
|
||||
/**
|
||||
* The tab list gives way to the date strip but keeps its scroll, so once the strip is drawn the
|
||||
* tab that matters can be wholly out of sight: after Go on a phone, the tab focus went back to
|
||||
* (0 of its 79 px at 360, 390 and 412 wide), and on a desktop window 640 to 770 px wide, the tab
|
||||
* of the panel left open. Brought back into view whenever the strip comes or goes: the focused
|
||||
* tab, else the selected one.
|
||||
*/
|
||||
private readonly keepTabInView = afterRenderEffect(() => {
|
||||
this.dateShown();
|
||||
const list = this.host.nativeElement.querySelector('[role="tablist"]');
|
||||
const focused = document.activeElement;
|
||||
const tab = focused && list?.contains(focused) ? focused : list?.querySelector('[aria-selected="true"]');
|
||||
// Optional: jsdom, which the unit tests run in, lays nothing out and has no scrollIntoView.
|
||||
tab?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.activeTab.set(isWideViewport() ? this.defaultTab() : null);
|
||||
this.fillDateField();
|
||||
}
|
||||
|
||||
tabLabel(tab: DockTab): string {
|
||||
return tab === 'display' && !this.display() ? 'Clock' : TAB_LABELS[tab];
|
||||
}
|
||||
|
||||
isOn(key: keyof HudDisplay): boolean {
|
||||
return this.display()?.[key] ?? false;
|
||||
}
|
||||
|
||||
toggleTab(tab: DockTab): void {
|
||||
this.activeTab.set(this.activeTab() === tab ? null : tab);
|
||||
this.fillDateField();
|
||||
}
|
||||
|
||||
/** The field's value is read as UTC, which is what the strip and the note print dates in. */
|
||||
goToDate(event: SubmitEvent): void {
|
||||
event.preventDefault();
|
||||
const field = (event.target as HTMLFormElement).elements.namedItem('date') as HTMLInputElement;
|
||||
if (this.time.setDate(new Date(`${field.value}Z`))) {
|
||||
// The field now says what the signal behind it does, so a later reset that fills it with
|
||||
// the present is a change the binding writes back, not one it drops as the same value.
|
||||
this.dateField.set(field.value);
|
||||
// On a phone the sheet covers the system it has just set the date of: at 360 by 640 every
|
||||
// orbit lies behind it. The thing to look at is now the scene, as after a search.
|
||||
// Focus goes back to the tab that folded, not to the page: the form it was in is gone.
|
||||
if (!isWideViewport()) {
|
||||
const tab = this.activeTab();
|
||||
this.activeTab.set(null);
|
||||
this.host.nativeElement.querySelector<HTMLElement>(`#dock-tab-${tab}`)?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
backToNow(): void {
|
||||
this.time.reset();
|
||||
this.fillDateField();
|
||||
}
|
||||
|
||||
private fillDateField(): void {
|
||||
this.dateField.set(this.time.date().toISOString().slice(0, 16));
|
||||
}
|
||||
|
||||
toggleLayer(key: keyof HudDisplay): void {
|
||||
const current = this.display();
|
||||
if (current) {
|
||||
this.displayChange.emit({ ...current, [key]: !current[key] });
|
||||
}
|
||||
}
|
||||
|
||||
/** A step on a plotted route was chosen: fly there, and leave the route up to walk it from. */
|
||||
onRouteStarSelected(starId: number): void {
|
||||
this.routeStarSelected.emit(starId);
|
||||
}
|
||||
|
||||
/** Choosing a kept place is the same move as choosing a search result: the panel has done
|
||||
* its job and the thing to look at is now the scene. */
|
||||
onBookmarkChosen(bookmark: Bookmark): void {
|
||||
this.bookmarkChosen.emit(bookmark);
|
||||
this.onPicked();
|
||||
}
|
||||
|
||||
onPicked(): void {
|
||||
// A result was chosen: the thing to look at is now the scene, so hand the panel back to the
|
||||
// readout where there is one, and fold the sheet away where there is not. On a narrow
|
||||
// viewport it always folds away — there the panel is a sheet over most of the scene, and
|
||||
// reopening it onto whatever was just flown to is the opposite of what was asked for.
|
||||
this.activeTab.set(isWideViewport() && this.title() ? 'readout' : null);
|
||||
}
|
||||
|
||||
/** `/` opens the search from anywhere, unless something else is already taking text. */
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key !== '/' || event.ctrlKey || event.metaKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.activeTab.set('search');
|
||||
// The field only exists after the panel renders; defer the focus to after that pass.
|
||||
setTimeout(() => this.search()?.focus());
|
||||
}
|
||||
|
||||
/** On a narrow viewport the panel is a sheet over the scene: tapping the scene folds it away. */
|
||||
@HostListener('document:pointerdown', ['$event'])
|
||||
onDocumentPointerDown(event: PointerEvent): void {
|
||||
if (
|
||||
this.activeTab() &&
|
||||
!isWideViewport() &&
|
||||
!this.host.nativeElement.contains(event.target as Node)
|
||||
) {
|
||||
this.activeTab.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
|
||||
|
||||
import { formatParsecs } from '../../shared/format/quantity';
|
||||
|
||||
/**
|
||||
* The widest jump the Routes panel offers. Past it the drawn graph is a solid sheet of lines, and
|
||||
* a route search through the dense core around the Sun walks thousands of stars at every step.
|
||||
*/
|
||||
export const MAX_JUMP_RANGE_PC = 8;
|
||||
|
||||
/** A star offered for one of the two fields, as the panel needs to show it. */
|
||||
export interface RouteStarOption {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly subtitle: string;
|
||||
}
|
||||
|
||||
/** What the scene worked out, once it has been asked. */
|
||||
export interface RouteResult {
|
||||
/** The chain, departure first. Empty when there is no route at the range asked for. */
|
||||
readonly stars: readonly { id: number; name: string }[];
|
||||
readonly totalPc: number;
|
||||
/** A range that would open a route, where none was found at the one asked for. */
|
||||
readonly neededRangePc: number | null;
|
||||
/** True when the search gave up rather than showing there is no route at this range. */
|
||||
readonly gaveUp: boolean;
|
||||
/** True when the search for a range that would work looked everywhere up to the widest offered. */
|
||||
readonly least: boolean;
|
||||
}
|
||||
|
||||
export interface RouteRequest {
|
||||
readonly fromId: number;
|
||||
readonly toId: number;
|
||||
readonly rangePc: number;
|
||||
}
|
||||
|
||||
/** Which end of the journey a query is for. */
|
||||
type Field = 'from' | 'to';
|
||||
|
||||
/**
|
||||
* Departure, destination, range, and the chain between them.
|
||||
*
|
||||
* The one genuinely two-sided tool in the instrument, and the reason the range lives here
|
||||
* rather than beside the layer toggle that draws the graph: the number that decides which
|
||||
* crossings are possible is the same number in both places, and a control is easier to trust
|
||||
* where its consequence is printed.
|
||||
*
|
||||
* Presentational. It knows how to ask; the scene knows the catalogue and does the walking.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-routes-panel',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
@for (field of fields; track field) {
|
||||
<div class="relative">
|
||||
<label [for]="'route-' + field" class="type-label text-muted">{{ field === 'from' ? 'Departure' : 'Destination' }}</label>
|
||||
<input
|
||||
[id]="'route-' + field"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
[value]="text(field)"
|
||||
[placeholder]="field === 'from' ? 'From this system' : 'Search a star'"
|
||||
(input)="onInput(field, $event)"
|
||||
(keydown.escape)="closeOptions()"
|
||||
class="hud-surface mt-1 w-full px-2.5 py-1.5 text-sm text-text caret-accent placeholder:text-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
@if (open() === field && options().length) {
|
||||
<ul class="hud-surface absolute bottom-full left-0 z-10 mb-1 max-h-48 w-full overflow-y-auto divide-y divide-border/25">
|
||||
@for (option of options(); track option.id) {
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
(click)="choose(field, option)"
|
||||
class="flex w-full items-baseline gap-3 px-2.5 py-1.5 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ option.name }}</span>
|
||||
<span class="type-label shrink-0 truncate text-muted">{{ option.subtitle }}</span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<label for="route-range" class="type-label shrink-0 text-muted">Jump range</label>
|
||||
<input
|
||||
id="route-range"
|
||||
type="range"
|
||||
[min]="minRangePc"
|
||||
[max]="maxRangePc"
|
||||
step="0.1"
|
||||
[value]="rangePc()"
|
||||
(input)="onRange($event)"
|
||||
class="h-1 min-w-0 flex-1 appearance-none rounded-none bg-border accent-accent"
|
||||
/>
|
||||
<output for="route-range" class="w-20 shrink-0 text-right text-sm text-accent tabular-nums">{{ rangeLabel() }}</output>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="!canPlot() || pending()"
|
||||
(click)="plot()"
|
||||
class="type-label border border-border/60 px-3 py-1.5 text-muted transition-colors enabled:hover:border-accent/70 enabled:hover:text-accent disabled:opacity-40 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
>
|
||||
{{ pending() ? 'Plotting…' : 'Plot route' }}
|
||||
</button>
|
||||
@if (result(); as plotted) {
|
||||
@if (plotted.stars.length) {
|
||||
<p data-testid="route-summary" class="text-sm text-text tabular-nums">
|
||||
{{ plotted.stars.length - 1 }} {{ plotted.stars.length === 2 ? 'jump' : 'jumps' }} <span class="text-muted">·</span> {{ format(plotted.totalPc) }}
|
||||
</p>
|
||||
} @else {
|
||||
<p data-testid="route-summary" class="text-sm text-muted">
|
||||
@if (plotted.gaveUp) {
|
||||
Too many stars to search at this range.
|
||||
} @else {
|
||||
No route at this range.
|
||||
}
|
||||
@if (plotted.neededRangePc !== null) {
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="!canPlot() || pending()"
|
||||
(click)="raiseTo(plotted.neededRangePc)"
|
||||
class="text-accent underline decoration-accent/40 underline-offset-2 disabled:opacity-40 disabled:no-underline enabled:hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent"
|
||||
>
|
||||
{{ format(plotted.neededRangePc) }} would reach.
|
||||
</button>
|
||||
} @else if (plotted.least) {
|
||||
No chain of jumps up to {{ format(maxRangePc) }} reaches it.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (result()?.stars?.length) {
|
||||
<ol data-testid="route-steps" class="hud-surface divide-y divide-border/25">
|
||||
@for (step of result()!.stars; track step.id; let i = $index) {
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
(click)="starSelected.emit(step.id)"
|
||||
class="flex w-full items-baseline gap-3 px-2.5 py-1.5 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
>
|
||||
<span class="type-label w-6 shrink-0 text-muted tabular-nums">{{ i + 1 }}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ step.name }}</span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class RoutesPanelComponent {
|
||||
/** Whatever the scene found for the last query it was given. */
|
||||
readonly result = input<RouteResult | null>(null);
|
||||
/** Matches for the field currently being typed into, ranked by the scene. */
|
||||
readonly options = input<readonly RouteStarOption[]>([]);
|
||||
/** The scene is still working the last request out; asking again would only queue behind it. */
|
||||
readonly pending = input(false);
|
||||
/** The star the view is currently inside, offered as the departure without typing. */
|
||||
readonly currentStar = input<RouteStarOption | null>(null);
|
||||
|
||||
readonly queryChange = output<string>();
|
||||
readonly routeRequested = output<RouteRequest>();
|
||||
readonly starSelected = output<number>();
|
||||
/** The graph is drawn at whatever range this panel is set to, so the scene follows it. */
|
||||
readonly rangeChange = output<number>();
|
||||
|
||||
readonly fields: readonly Field[] = ['from', 'to'];
|
||||
/** A tenth of a parsec is finer than the catalogue's own distances are known to. */
|
||||
readonly minRangePc = 0.5;
|
||||
readonly maxRangePc = MAX_JUMP_RANGE_PC;
|
||||
|
||||
readonly rangePc = signal(3);
|
||||
readonly open = signal<Field | null>(null);
|
||||
|
||||
private readonly chosen = signal<Record<Field, RouteStarOption | null>>({ from: null, to: null });
|
||||
private readonly typed = signal<Record<Field, string>>({ from: '', to: '' });
|
||||
|
||||
/**
|
||||
* Departure falls back to wherever the view already is, so one field is usually enough — but only
|
||||
* while the field is empty. Text left in it that names no chosen star used to fall back all the
|
||||
* same, so the panel read "Sol" and the route left from whatever the view had since flown to.
|
||||
*/
|
||||
// Trimmed, as the scene trims the same text before offering matches for it: a field holding one
|
||||
// space looks empty, offers nothing to choose, and would otherwise count as a departure.
|
||||
private readonly departure = computed(() => this.chosen().from ?? (this.typed().from.trim() ? null : this.currentStar()));
|
||||
|
||||
readonly canPlot = computed(() => this.departure() !== null && this.chosen().to !== null);
|
||||
readonly rangeLabel = computed(() => formatParsecs(this.rangePc()));
|
||||
|
||||
text(field: Field): string {
|
||||
return this.chosen()[field]?.name ?? this.typed()[field];
|
||||
}
|
||||
|
||||
format(distancePc: number): string {
|
||||
return formatParsecs(distancePc);
|
||||
}
|
||||
|
||||
onInput(field: Field, event: Event): void {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
this.typed.update((current) => ({ ...current, [field]: value }));
|
||||
// Typing over a chosen star un-chooses it: the field says what it will be searched for.
|
||||
this.chosen.update((current) => ({ ...current, [field]: null }));
|
||||
this.open.set(field);
|
||||
this.queryChange.emit(value);
|
||||
}
|
||||
|
||||
choose(field: Field, option: RouteStarOption): void {
|
||||
this.chosen.update((current) => ({ ...current, [field]: option }));
|
||||
this.closeOptions();
|
||||
}
|
||||
|
||||
closeOptions(): void {
|
||||
this.open.set(null);
|
||||
this.queryChange.emit('');
|
||||
}
|
||||
|
||||
onRange(event: Event): void {
|
||||
const value = Number((event.target as HTMLInputElement).value);
|
||||
this.rangePc.set(value);
|
||||
this.rangeChange.emit(value);
|
||||
}
|
||||
|
||||
raiseTo(rangePc: number): void {
|
||||
// Rounded up to the control's own step, so the number shown is one it can actually hold —
|
||||
// and up rather than down, since down would land just short of the crossing it names.
|
||||
const stepped = Math.min(this.maxRangePc, Math.ceil(rangePc * 10) / 10);
|
||||
this.rangePc.set(stepped);
|
||||
this.rangeChange.emit(stepped);
|
||||
this.plot();
|
||||
}
|
||||
|
||||
plot(): void {
|
||||
const from = this.departure();
|
||||
const to = this.chosen().to;
|
||||
if (from && to) {
|
||||
this.routeRequested.emit({ fromId: from.id, toId: to.id, rangePc: this.rangePc() });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,11 @@ describe('rankSearchResults', () => {
|
||||
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', () => {
|
||||
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']);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { spectralClassification } from '../../shared/astro/spectral';
|
||||
|
||||
export type SearchResultKind = 'star' | 'body' | 'exoplanet';
|
||||
|
||||
export interface SearchEntry {
|
||||
@@ -6,22 +8,40 @@ export interface SearchEntry {
|
||||
subtitle: string;
|
||||
/** HYG star id, for `kind: 'star'` results. */
|
||||
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. */
|
||||
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
|
||||
* prefix match, and so on, so a better kind of match can never be crowded out by a worse one.
|
||||
*/
|
||||
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_WORD_START = 2;
|
||||
const MATCH_SUBSTRING = 1;
|
||||
const NO_MATCH = 0;
|
||||
|
||||
/**
|
||||
* Order for results that match equally well. Solar-system bodies are eighteen famous objects
|
||||
* 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"
|
||||
* 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;
|
||||
}
|
||||
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))) {
|
||||
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. */
|
||||
const STARS: StarRecord[] = [
|
||||
...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 = {
|
||||
@@ -34,7 +37,9 @@ const IO: BodyRecord = {
|
||||
argumentOfPeriapsisDeg: 0,
|
||||
meanAnomalyAtEpochDeg: 0,
|
||||
epochJd: 2451545.0
|
||||
}
|
||||
},
|
||||
rates: { meanMotionDegPerDay: 203.4889583, longitudeOfAscendingNodeDegPerDay: 0, argumentOfPeriapsisDegPerDay: 0 },
|
||||
orbitSource: 'test'
|
||||
};
|
||||
|
||||
const PROXIMA_B: ExoplanetRecord = {
|
||||
@@ -109,6 +114,17 @@ describe('SearchComponent', () => {
|
||||
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 () => {
|
||||
await type('zzzzz');
|
||||
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Component, computed, signal } from '@angular/core';
|
||||
import { Component, computed, ElementRef, output, signal, viewChild } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
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 MIN_QUERY_LENGTH = 2;
|
||||
@@ -25,13 +25,17 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
|
||||
selector: 'app-search',
|
||||
imports: [ReticleIconComponent],
|
||||
template: `
|
||||
<div class="fixed top-4 left-1/2 z-20 w-[26rem] max-w-[calc(100%-2rem)] -translate-x-1/2 font-body">
|
||||
<!-- Sits on the dock's tab strip: the field is pinned to the bottom of the block and the
|
||||
results grow upward above it, so typing never moves the thing being typed into. DOM
|
||||
order stays field-then-results (flex-col-reverse), which is also the focus order. -->
|
||||
<div class="flex flex-col-reverse font-body">
|
||||
<!-- The one genuinely translucent surface, so it alone carries a (small) backdrop blur.
|
||||
The ring is the keyboard-focus indicator: a border hue shift alone is invisible over
|
||||
the star field. -->
|
||||
<div class="hud-brackets hud-surface relative backdrop-blur-sm transition-colors focus-within:border-accent focus-within:ring-1 focus-within:ring-accent/50">
|
||||
<app-reticle-icon class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-accent/70" />
|
||||
<input
|
||||
#field
|
||||
type="text"
|
||||
placeholder="Search stars, planets, exoplanets…"
|
||||
[value]="query()"
|
||||
@@ -42,11 +46,10 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
|
||||
</div>
|
||||
|
||||
<!-- One panel for both outcomes, so crossing the match boundary while typing swaps only
|
||||
the rows instead of remounting the panel and replaying its acquire wipe per
|
||||
keystroke. Gated on the index being ready: "no matches" may only ever describe a
|
||||
search that actually ran against the loaded catalogues. -->
|
||||
the rows instead of remounting the panel. Gated on the index being ready: "no matches"
|
||||
may only ever describe a search that actually ran against the loaded catalogues. -->
|
||||
@if (indexReady() && hasQuery()) {
|
||||
<div class="hud-acquire hud-surface mt-2">
|
||||
<div class="hud-surface mb-2">
|
||||
@if (results().length) {
|
||||
<p class="type-label border-b border-border/40 px-3 py-1.5 text-muted">
|
||||
Matches <span class="text-accent tabular-nums">{{ matchTotal() }}</span>
|
||||
@@ -63,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"
|
||||
>
|
||||
<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>
|
||||
</li>
|
||||
}
|
||||
@@ -78,6 +81,11 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
|
||||
})
|
||||
export class SearchComponent {
|
||||
readonly query = signal('');
|
||||
/** Fires once a result has been chosen and navigation kicked off — the dock uses it to hand
|
||||
* the view back to the readout, since the thing to look at is now the scene, not the box. */
|
||||
readonly picked = output<void>();
|
||||
|
||||
private readonly field = viewChild.required<ElementRef<HTMLInputElement>>('field');
|
||||
/** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */
|
||||
private readonly index = signal<IndexedSearchEntry[]>([]);
|
||||
/** False until the catalogues have loaded — and forever if they fail, which beats a false
|
||||
@@ -99,7 +107,7 @@ export class SearchComponent {
|
||||
});
|
||||
|
||||
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(
|
||||
private readonly dataLoader: DataLoaderService,
|
||||
@@ -117,6 +125,10 @@ export class SearchComponent {
|
||||
this.query.set('');
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.field().nativeElement.focus();
|
||||
}
|
||||
|
||||
kindLabel(kind: SearchResultKind): string {
|
||||
return KIND_LABELS[kind];
|
||||
}
|
||||
@@ -129,6 +141,7 @@ export class SearchComponent {
|
||||
} else if (result.bodyId) {
|
||||
void this.router.navigate(['/body', result.bodyId]);
|
||||
}
|
||||
this.picked.emit();
|
||||
}
|
||||
|
||||
private async buildIndex(): Promise<void> {
|
||||
@@ -140,7 +153,7 @@ export class SearchComponent {
|
||||
]);
|
||||
|
||||
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 })),
|
||||
...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 { 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 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. */
|
||||
const EUROPA: BodyRecord = { id: 'europa', systemStarId: 0, name: 'Europa', kind: 'moon', radiusKm: 1560, parentBodyId: 'jupiter', orbit: { ...ORBIT, semiMajorAxisAu: 0.00449 } };
|
||||
const EARTH: BodyRecord = { id: 'earth', systemStarId: 0, name: 'Earth', kind: 'planet', radiusKm: 6371, orbit: { ...ORBIT, semiMajorAxisAu: 1 } };
|
||||
const 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 }, rates: RATES, orbitSource: 'test' };
|
||||
const ORPHAN: BodyRecord = { ...EUROPA, id: 'orphan', parentBodyId: 'nowhere' };
|
||||
|
||||
const BODIES = [JUPITER, EUROPA, EARTH, ORPHAN];
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { brightestWithin, brightnessIndex, brightnessOrder } from './brightest';
|
||||
|
||||
interface TestStar {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
magnitude: number;
|
||||
}
|
||||
|
||||
/** A pseudo-random cloud with repeated magnitudes, so ties are exercised. */
|
||||
function cloud(count: number): TestStar[] {
|
||||
let seed = 5;
|
||||
const random = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
|
||||
return Array.from({ length: count }, (_, id) => ({
|
||||
id: id * 7,
|
||||
x: random() * 200 - 100,
|
||||
y: random() * 200 - 100,
|
||||
z: random() * 200 - 100,
|
||||
magnitude: Math.round(random() * 40) / 4
|
||||
}));
|
||||
}
|
||||
|
||||
describe('brightnessOrder', () => {
|
||||
it('puts the brightest first and keeps catalogue order among equals', () => {
|
||||
const stars = [{ magnitude: 5 }, { magnitude: -1 }, { magnitude: 5 }, { magnitude: 2 }];
|
||||
|
||||
expect(Array.from(brightnessOrder(stars))).toEqual([1, 3, 0, 2]);
|
||||
});
|
||||
|
||||
it('orders nothing for an empty catalogue', () => {
|
||||
expect(brightnessOrder([])).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('brightestWithin', () => {
|
||||
// What the labels used to do on every pass: filter the whole catalogue, then sort what was left.
|
||||
function filterThenSort(stars: TestStar[], centre: { x: number; y: number; z: number }, radius: number, alwaysId: number | null): number[] {
|
||||
return stars
|
||||
.filter((star) => Math.hypot(star.x - centre.x, star.y - centre.y, star.z - centre.z) <= radius || star.id === alwaysId)
|
||||
.sort((a, b) => a.magnitude - b.magnitude)
|
||||
.map((star) => star.id);
|
||||
}
|
||||
|
||||
it('yields exactly what filtering and then sorting the catalogue did, in the same order', () => {
|
||||
const stars = cloud(3000);
|
||||
const index = brightnessIndex(stars);
|
||||
const centre = { x: 12, y: -30, z: 5 };
|
||||
|
||||
for (const [radius, alwaysId] of [[40, null], [15, 7 * 2999], [0, 7 * 11], [500, null]] as const) {
|
||||
const lazy = Array.from(brightestWithin(stars, index, centre, radius, alwaysId), (star) => star.id);
|
||||
expect(lazy).toEqual(filterThenSort(stars, centre, radius, alwaysId));
|
||||
}
|
||||
});
|
||||
|
||||
it('includes a star lying exactly on the radius, as the scan it replaced did', () => {
|
||||
const stars = [
|
||||
{ id: 1, x: 3, y: 4, z: 0, magnitude: 1 },
|
||||
{ id: 2, x: 3, y: 4.001, z: 0, magnitude: 0 }
|
||||
];
|
||||
|
||||
expect(Array.from(brightestWithin(stars, brightnessIndex(stars), { x: 0, y: 0, z: 0 }, 5, null), (star) => star.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('reads no further than the caller takes', () => {
|
||||
const stars = cloud(3000);
|
||||
let read = 0;
|
||||
const counted = new Proxy(stars, {
|
||||
get(target, key, receiver) {
|
||||
if (typeof key === 'string' && /^\d+$/.test(key)) {
|
||||
read++;
|
||||
}
|
||||
return Reflect.get(target, key, receiver);
|
||||
}
|
||||
});
|
||||
|
||||
const taken: number[] = [];
|
||||
for (const star of brightestWithin(counted, brightnessIndex(stars), { x: 0, y: 0, z: 0 }, 1000, null)) {
|
||||
taken.push(star.id);
|
||||
if (taken.length === 15) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(taken).toHaveLength(15);
|
||||
expect(read).toBe(15);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* The catalogue in order of brightness, worked out once and walked as often as needed.
|
||||
*
|
||||
* Two parts of the map want "the brightest stars in this region": the labels, which name about
|
||||
* fifteen of them five times a second, and the star field, which draws a budget of them. Sorting
|
||||
* the region each time is paid for every star in it. At the opening view the label region holds
|
||||
* some 60 000 stars, and sorting them to name fifteen took 55-70 ms a pass, a stall five times
|
||||
* a second on any machine. Walking one shared order and stopping when enough have been taken
|
||||
* costs only the stars looked at before that.
|
||||
*/
|
||||
|
||||
export interface BrightnessRanked {
|
||||
readonly magnitude: number;
|
||||
}
|
||||
|
||||
export interface Positioned {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly z: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indices into `stars`, brightest (lowest magnitude) first. Ties keep catalogue order: typed-array
|
||||
* sort is required to be stable, exactly as the sort of the stars themselves was.
|
||||
*/
|
||||
export function brightnessOrder(stars: readonly BrightnessRanked[]): Uint32Array {
|
||||
// Compared from a typed copy rather than off the stars: the sort reads two magnitudes per
|
||||
// comparison, some eight million times for the whole catalogue: 83 ms this way, 104-139 ms reading them off the stars.
|
||||
const magnitudes = Float64Array.from(stars, (star) => star.magnitude);
|
||||
return Uint32Array.from(stars.keys()).sort((a, b) => magnitudes[a] - magnitudes[b]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The brightness order, with each star's position and id laid out beside it in that order.
|
||||
*
|
||||
* A walk has to test every star it passes, and near the Sun it passes nearly all of them: a 4 pc
|
||||
* label radius holds a few dozen stars, faint dwarfs deep in the order, so the walk rarely finds
|
||||
* fifteen to name before the end. Reading the stars themselves in brightness order jumps all over
|
||||
* the catalogue, and a full walk took 19-23 ms — slower than the scan and sort it replaced. Read
|
||||
* from these arrays, laid out in the order they are walked, the same walk touches memory in
|
||||
* sequence and reads a star only when it yields one.
|
||||
*/
|
||||
export interface BrightnessIndex {
|
||||
/** Indices into the catalogue, brightest first. */
|
||||
readonly order: Uint32Array;
|
||||
/** Positions in the same order, three to a star, at full precision so a star on a radius stays on it. */
|
||||
readonly positions: Float64Array;
|
||||
readonly ids: Float64Array;
|
||||
}
|
||||
|
||||
export function brightnessIndex<T extends BrightnessRanked & Positioned & { readonly id: number }>(stars: readonly T[]): BrightnessIndex {
|
||||
const order = brightnessOrder(stars);
|
||||
const positions = new Float64Array(order.length * 3);
|
||||
const ids = new Float64Array(order.length);
|
||||
order.forEach((index, at) => {
|
||||
const star = stars[index];
|
||||
positions[at * 3] = star.x;
|
||||
positions[at * 3 + 1] = star.y;
|
||||
positions[at * 3 + 2] = star.z;
|
||||
ids[at] = star.id;
|
||||
});
|
||||
return { order, positions, ids };
|
||||
}
|
||||
|
||||
/**
|
||||
* The stars within `radiusPc` of `centre`, brightest first, plus the one star `alwaysId` names
|
||||
* wherever it is — handed over lazily, so a caller that stops after the first few pays for no
|
||||
* more than it read.
|
||||
*/
|
||||
export function* brightestWithin<T extends BrightnessRanked & Positioned & { readonly id: number }>(
|
||||
stars: readonly T[],
|
||||
index: BrightnessIndex,
|
||||
centre: Positioned,
|
||||
radiusPc: number,
|
||||
alwaysId: number | null
|
||||
): Generator<T> {
|
||||
const { order, positions, ids } = index;
|
||||
const radiusSq = radiusPc * radiusPc;
|
||||
for (let at = 0; at < order.length; at++) {
|
||||
const dx = positions[at * 3] - centre.x;
|
||||
const dy = positions[at * 3 + 1] - centre.y;
|
||||
const dz = positions[at * 3 + 2] - centre.z;
|
||||
if (dx * dx + dy * dy + dz * dz <= radiusSq || ids[at] === alwaysId) {
|
||||
yield stars[order[at]];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* Approximate planet/Sun mass ratios for the major planets that host moons in `bodies.json`.
|
||||
* Used to derive each planet's gravitational parameter (for propagating its moons) as
|
||||
* `GM_SUN_AU3_PER_DAY2 * massRatio`. Precise enough for visualization; not JPL-grade.
|
||||
*/
|
||||
const PLANET_TO_SUN_MASS_RATIO: Record<string, number> = {
|
||||
earth: 3.003e-6,
|
||||
mars: 3.227e-7,
|
||||
jupiter: 9.545e-4,
|
||||
saturn: 2.857e-4,
|
||||
uranus: 4.365e-5,
|
||||
neptune: 5.151e-5
|
||||
};
|
||||
/** The first day of each month UTC took a leap second at the start of, from its 10 s of 1972. */
|
||||
const LEAP_SECONDS_FROM = [
|
||||
[1972, 7], [1973, 1], [1974, 1], [1975, 1], [1976, 1], [1977, 1], [1978, 1], [1979, 1], [1980, 1], [1981, 7],
|
||||
[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]
|
||||
].map(([year, month]) => Date.UTC(year, month - 1, 1) / 86400000 + 2440587.5);
|
||||
const JD_1972 = Date.UTC(1972, 0, 1) / 86400000 + 2440587.5;
|
||||
|
||||
/**
|
||||
* Gravitational parameter (AU^3/day^2) to use when propagating a body's orbit: the Sun's
|
||||
* for planets/dwarfs/exoplanets, or the host planet's (derived from its Sun mass ratio) for
|
||||
* moons. Falls back to the Sun's GM if `parentBodyId` isn't a known planet.
|
||||
* TT - UT, in seconds, at a date on the map's clock: how far Earth's turning, which UT counts,
|
||||
* has fallen behind the uniform time the ephemerides run on.
|
||||
*
|
||||
* 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 {
|
||||
if (!parentBodyId) {
|
||||
return GM_SUN_AU3_PER_DAY2;
|
||||
export function ttMinusUtSeconds(jdUt: number): number {
|
||||
if (jdUt >= JD_1972) {
|
||||
return 32.184 + 10 + LEAP_SECONDS_FROM.filter((from) => jdUt >= from).length;
|
||||
}
|
||||
const massRatio = PLANET_TO_SUN_MASS_RATIO[parentBodyId];
|
||||
return massRatio ? GM_SUN_AU3_PER_DAY2 * massRatio : GM_SUN_AU3_PER_DAY2;
|
||||
const y = 2000 + (jdUt - 2451544.5) / 365.2425;
|
||||
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". */
|
||||
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
distanceBetween,
|
||||
eclipticToEquatorial,
|
||||
equatorialToEcliptic,
|
||||
laplacePlaneToEquatorial,
|
||||
OBLIQUITY_J2000_DEG,
|
||||
parallaxMasToParsecs,
|
||||
parseSexagesimal,
|
||||
propagateProperMotion,
|
||||
raDecDistanceToXyz,
|
||||
raDecToUnitVector,
|
||||
raDegDecDistanceToXyz
|
||||
@@ -59,6 +61,27 @@ describe('raDegDecDistanceToXyz', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('propagateProperMotion', () => {
|
||||
it("carries Barnard's Star from Gaia's epoch back to HYG's", () => {
|
||||
// Gaia DR3 4472832130942575872 as published for J2016.0, moved back sixteen years with its
|
||||
// own proper motion, lands on the J2000.0 position SIMBAD lists to a milliarcsecond — and
|
||||
// 0.08″ from where HYG has Barnard's Star, instead of the 166″ the two epochs put between them.
|
||||
const j2000 = propagateProperMotion(269.44850252543836, 4.739420051112412, -801.550978, 10362.394207, -16);
|
||||
expect(j2000.raDeg).toBeCloseTo(269.4520772, 6);
|
||||
expect(j2000.decDeg).toBeCloseTo(4.693365, 6);
|
||||
});
|
||||
|
||||
it('divides the right-ascension motion by cos δ, since pmra is published on the sky', () => {
|
||||
// 3600 mas/yr for one year is 3.6″ on the sky; at Dec 60° that is 7.2″ of right ascension.
|
||||
expect(propagateProperMotion(0, 60, 3600, 0, 1).raDeg).toBeCloseTo(7.2 / 3600, 9);
|
||||
expect(propagateProperMotion(0, 60, 0, 3600, 1).decDeg).toBeCloseTo(60 + 3.6 / 3600, 9);
|
||||
});
|
||||
|
||||
it('leaves a star with no proper motion where it is', () => {
|
||||
expect(propagateProperMotion(100, -20, 0, 0, 16)).toEqual({ raDeg: 100, decDeg: -20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parallaxMasToParsecs', () => {
|
||||
it('converts a positive parallax to the expected distance', () => {
|
||||
expect(parallaxMasToParsecs(769.33)).toBeCloseTo(1.3, 2); // Proxima Centauri
|
||||
@@ -184,6 +207,26 @@ describe('eclipticToEquatorial', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('laplacePlaneToEquatorial', () => {
|
||||
const RAD = Math.PI / 180;
|
||||
/** Jupiter's moons' Laplace pole, as JPL gives it for Io. */
|
||||
const POLE = { raDeg: 268.057, decDeg: 64.495 };
|
||||
|
||||
it('sends the plane’s own pole to the right ascension and declination it is named by', () => {
|
||||
const pole = laplacePlaneToEquatorial({ x: 0, y: 0, z: 1 }, POLE);
|
||||
expect(Math.asin(pole.z) / RAD).toBeCloseTo(POLE.decDeg, 9);
|
||||
expect(((Math.atan2(pole.y, pole.x) / RAD) + 360) % 360).toBeCloseTo(POLE.raDeg, 9);
|
||||
});
|
||||
|
||||
it('counts the node from where the plane rises through the equator, 90 degrees past the pole', () => {
|
||||
const node = laplacePlaneToEquatorial({ x: 1, y: 0, z: 0 }, POLE);
|
||||
expect(node.z).toBeCloseTo(0, 12);
|
||||
expect(((Math.atan2(node.y, node.x) / RAD) + 360) % 360).toBeCloseTo((POLE.raDeg + 90) % 360, 9);
|
||||
// Rising: a quarter-turn on along the plane is north of the equator.
|
||||
expect(laplacePlaneToEquatorial({ x: 0, y: 1, z: 0 }, POLE).z).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equatorialToEcliptic', () => {
|
||||
it('is the exact inverse of eclipticToEquatorial', () => {
|
||||
for (const point of [
|
||||
|
||||
@@ -33,6 +33,21 @@ export function raDegDecDistanceToXyz(raDeg: number, decDeg: number, distancePc:
|
||||
return raDecDistanceToXyz(raDeg / HOURS_TO_DEG, decDeg, distancePc);
|
||||
}
|
||||
|
||||
const MAS_TO_DEG = 1 / 3_600_000;
|
||||
|
||||
/**
|
||||
* Moves a sky position along its proper motion by `years` — negative to go back in time — so
|
||||
* catalogues that observed at different epochs can be compared at one. `pmRaMasPerYear` is
|
||||
* μα cos δ, the on-sky rate Hipparcos and Gaia both publish, hence the division by cos δ to turn
|
||||
* it back into right ascension.
|
||||
*/
|
||||
export function propagateProperMotion(raDeg: number, decDeg: number, pmRaMasPerYear: number, pmDecMasPerYear: number, years: number): { raDeg: number; decDeg: number } {
|
||||
return {
|
||||
raDeg: raDeg + (years * pmRaMasPerYear * MAS_TO_DEG) / Math.cos(decDeg * DEG_TO_RAD),
|
||||
decDeg: decDeg + years * pmDecMasPerYear * MAS_TO_DEG
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obliquity of the ecliptic at J2000.0, in degrees — the tilt of Earth's orbital plane against
|
||||
* its equator, and so the angle between this app's two source frames.
|
||||
@@ -44,8 +59,8 @@ export const OBLIQUITY_J2000_DEG = 23.4392911;
|
||||
*
|
||||
* The app has to span both because its two sources disagree. Star positions come from HYG as
|
||||
* equatorial coordinates, which `raDecDistanceToXyz` produces and which the galaxy view renders
|
||||
* directly. Orbital elements come from JPL Horizons, whose default reference plane for element
|
||||
* output is the ecliptic — the ETL never overrides it. The two are tilted
|
||||
* directly. The planets' and the Moon's orbital elements are JPL mean elements against the J2000
|
||||
* ecliptic. The two are tilted
|
||||
* {@link OBLIQUITY_J2000_DEG} apart about the shared vernal-equinox axis, so orbits have to be
|
||||
* rotated before they can share a scene with the stars.
|
||||
*/
|
||||
@@ -63,6 +78,29 @@ export function eclipticToEquatorial(position: CartesianCoordinates): CartesianC
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates a vector from a moon's local **Laplace plane** frame into the equatorial one.
|
||||
*
|
||||
* JPL gives the giant planets' moons against the plane their orbits precess about, which lies
|
||||
* between the planet's equator and its orbit, and names it by its pole. The frame's x axis is
|
||||
* where that plane rises through the ICRF equator, at right ascension 90 degrees past the pole's,
|
||||
* which is what the node is counted from; its z axis is the pole, 90 degrees less its declination
|
||||
* away from the celestial one. Read against the ecliptic instead, Io was up to 2.8 degrees from
|
||||
* where Horizons has it between 1950 and 2100, Phobos 54 and Titan 127: their nodes are counted
|
||||
* from a different line altogether.
|
||||
*/
|
||||
export function laplacePlaneToEquatorial(position: CartesianCoordinates, pole: { raDeg: number; decDeg: number }): CartesianCoordinates {
|
||||
const tilt = (90 - pole.decDeg) * DEG_TO_RAD;
|
||||
const node = (pole.raDeg + 90) * DEG_TO_RAD;
|
||||
const y = position.y * Math.cos(tilt) - position.z * Math.sin(tilt);
|
||||
const z = position.y * Math.sin(tilt) + position.z * Math.cos(tilt);
|
||||
return {
|
||||
x: position.x * Math.cos(node) - y * Math.sin(node),
|
||||
y: position.x * Math.sin(node) + y * Math.cos(node),
|
||||
z
|
||||
};
|
||||
}
|
||||
|
||||
/** Inverse of {@link eclipticToEquatorial}. */
|
||||
export function equatorialToEcliptic(position: CartesianCoordinates): CartesianCoordinates {
|
||||
const obliquity = OBLIQUITY_J2000_DEG * DEG_TO_RAD;
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('classifyOpenNgcType', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
@@ -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', () => {
|
||||
for (const type of ['', ' ', 'wat', undefined, null]) {
|
||||
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.
|
||||
* 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>> = {
|
||||
// Galaxies, and multi-galaxy systems.
|
||||
@@ -52,7 +55,6 @@ const KIND_BY_OPENNGC_TYPE: Readonly<Record<string, DeepSkyKind>> = {
|
||||
EmN: 'nebula',
|
||||
RfN: 'nebula',
|
||||
Neb: 'nebula',
|
||||
DrkN: 'nebula',
|
||||
SNR: 'nebula',
|
||||
'Cl+N': 'nebula',
|
||||
// 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,9 +1,14 @@
|
||||
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 { StarRecord } from '../models/star.model';
|
||||
|
||||
// A small fixture standing in for a slice of the HYG star index, used to exercise the
|
||||
function star(id: number, name: string, raDeg: number, decDeg: number, distancePc: number): StarRecord {
|
||||
return { id, name, ...raDegDecDistanceToXyz(raDeg, decDeg, distancePc), magnitude: 10, spectralType: 'M', colorIndex: 1.0 };
|
||||
}
|
||||
|
||||
// A small fixture standing in for a slice of the star catalogue, used to exercise the
|
||||
// exoplanet host-star cross-referencing logic without hitting any real API.
|
||||
const FIXTURE_STARS: StarRecord[] = [
|
||||
// The Sun sits at the origin, exactly where a host with a missing distance lands.
|
||||
@@ -22,46 +27,161 @@ describe('normalizeStarName', () => {
|
||||
|
||||
describe('resolveHostStarId', () => {
|
||||
it('matches by exact (normalized) host star name', () => {
|
||||
const id = resolveHostStarId({ hostname: 'Proxima Centauri', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS, 0.5);
|
||||
const id = resolveHostStarId({ hostname: 'Proxima Centauri', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS);
|
||||
|
||||
expect(id).toBe(1);
|
||||
});
|
||||
|
||||
it('matches by name regardless of case/spacing differences', () => {
|
||||
const id = resolveHostStarId({ hostname: 'gj3512', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS, 0.5);
|
||||
const id = resolveHostStarId({ hostname: 'gj3512', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS);
|
||||
|
||||
expect(id).toBe(3);
|
||||
});
|
||||
|
||||
it('falls back to nearest-neighbour position matching when the name is unknown', () => {
|
||||
// Slightly off from Sirius's exact position, within tolerance.
|
||||
const id = resolveHostStarId({ hostname: 'Sirius A', raDeg: 101.29, decDeg: -16.72, distancePc: 2.64 }, FIXTURE_STARS, 0.5);
|
||||
it('returns null when there is no name match and no position is available', () => {
|
||||
const id = resolveHostStarId({ hostname: 'Unknown Star XYZ', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS);
|
||||
|
||||
expect(id).toBeNull();
|
||||
});
|
||||
|
||||
describe('matching on the sky', () => {
|
||||
// GJ 887's archive row: position at Gaia's epoch, carried by 6.9″/yr of proper motion —
|
||||
// 110″ from where the catalogue has the star at J2000. The matcher must carry the query
|
||||
// back those sixteen years itself, and judge each star on the better of the two epochs: the
|
||||
// decoy standing halfway along the star's own track is nearer than Lacaille 9352 at the
|
||||
// published point *and* nearer at the worse of the two epochs, so it wins unless the
|
||||
// carried-back position is tried and the best epoch — not the worst — decides.
|
||||
it('matches a host published at the Gaia epoch to its star at J2000, past a decoy on its track', () => {
|
||||
const lacaille9352 = star(70, 'Lacaille 9352', 346.46683, -35.85306, 3.29);
|
||||
const archive = propagateProperMotion(346.46683, -35.85306, 6768.2, 1327.52, 16);
|
||||
const decoy = star(71, 'Decoy', (346.46683 + archive.raDeg) / 2, (-35.85306 + archive.decDeg) / 2, 3.29);
|
||||
|
||||
const id = resolveHostStarId(
|
||||
{ hostname: 'GJ 887', raDeg: archive.raDeg, decDeg: archive.decDeg, distancePc: 3.28679, pmRaMasPerYear: 6768.2, pmDecMasPerYear: 1327.52 },
|
||||
[decoy, lacaille9352]
|
||||
);
|
||||
|
||||
expect(id).toBe(70);
|
||||
});
|
||||
|
||||
// alf Tau's archive row publishes J2000 outright, and the archive never says which epoch a
|
||||
// row is at. If the matcher trusted one epoch and carried every query back, Aldebaran's
|
||||
// planet would land on the Gliese entry sitting 3″ from the carried-back point; the raw
|
||||
// position, zero arcseconds from Aldebaran itself, has to win.
|
||||
it('keeps a host published at J2000 on its star, proper motion or not', () => {
|
||||
const aldebaran = star(80, 'Aldebaran', 68.980163, 16.509302, 20.43);
|
||||
const carried = propagateProperMotion(68.980163, 16.509302, 63, -189, -16);
|
||||
const ghost = star(81, 'Gl 171.1B', carried.raDeg, carried.decDeg + 3 / 3600, 20.43);
|
||||
|
||||
const id = resolveHostStarId(
|
||||
{ hostname: 'alf Tau', raDeg: 68.980163, decDeg: 16.509302, distancePc: 20.43, pmRaMasPerYear: 63, pmDecMasPerYear: -189 },
|
||||
[ghost, aldebaran]
|
||||
);
|
||||
|
||||
expect(id).toBe(80);
|
||||
});
|
||||
|
||||
// GJ 15 A's archive row sits at 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
|
||||
// picks the interloper; carrying the query back the fifteen and a half years must put the
|
||||
// planets on the star that actually moved there.
|
||||
it('picks the star the proper motion says the query is, not the entry nearest the published point', () => {
|
||||
const primary = star(90, 'Groombridge 34', 4.595364, 44.022955, 3.562);
|
||||
const published = propagateProperMotion(4.595364, 44.022955, 2891.5, 411.9, 15.5);
|
||||
const interloper = star(91, 'Gaia DR3 385334196532776576', published.raDeg, published.decDeg + 16 / 3600, 3.563);
|
||||
|
||||
const id = resolveHostStarId(
|
||||
{ hostname: 'GJ 15 A', raDeg: published.raDeg, decDeg: published.decDeg, distancePc: 3.56228, pmRaMasPerYear: 2891.5, pmDecMasPerYear: 411.9 },
|
||||
[interloper, primary]
|
||||
);
|
||||
|
||||
expect(id).toBe(90);
|
||||
});
|
||||
|
||||
// GJ 273 is Luyten's Star to the arcsecond, but the archive publishes 5.92 pc for a star
|
||||
// at 3.79 — a 56% disagreement. Direction alone must not override a distance in flat
|
||||
// contradiction, or every line-of-sight coincidence becomes a match.
|
||||
it('refuses a host whose distance flatly contradicts the star it points at', () => {
|
||||
const luytens = star(100, "Luyten's Star", 111.8496, 5.2258, 3.79);
|
||||
|
||||
const id = resolveHostStarId({ hostname: 'GJ 273', raDeg: 111.8496, decDeg: 5.2258, distancePc: 5.921535 }, [luytens]);
|
||||
|
||||
expect(id).toBeNull();
|
||||
});
|
||||
|
||||
// The 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
|
||||
// is a match at 50 pc and a stranger at 200 pc.
|
||||
it('scales the angular tolerance with the host distance', () => {
|
||||
const at200 = resolveHostStarId(
|
||||
{ hostname: 'Unmatched', raDeg: 150, decDeg: -40 + 15 / 3600, distancePc: 200 },
|
||||
[star(110, 'Far', 150, -40, 200)]
|
||||
);
|
||||
const at50 = resolveHostStarId(
|
||||
{ hostname: 'Unmatched', raDeg: 150, decDeg: -40 + 15 / 3600, distancePc: 50 },
|
||||
[star(111, 'Near', 150, -40, 50)]
|
||||
);
|
||||
|
||||
expect(at200).toBeNull();
|
||||
expect(at50).toBe(111);
|
||||
});
|
||||
|
||||
// A star whose distance disqualifies it is not merely rejected — it must not become the
|
||||
// best-so-far either, or an unmerged twin with a bad parallax, sitting nearer on the sky
|
||||
// than the true host, silently unhosts the planet by outranking a star that is never
|
||||
// allowed to win.
|
||||
it('does not let a star its distance disqualifies shadow the true host behind it', () => {
|
||||
const badParallaxTwin = star(120, 'Gaia DR3 twin', 40, 12 + 1 / 3600, 480);
|
||||
const host = star(121, 'True host', 40, 12 + 3 / 3600, 100);
|
||||
|
||||
const id = resolveHostStarId({ hostname: 'Unmatched', raDeg: 40, decDeg: 12, distancePc: 100 }, [badParallaxTwin, host]);
|
||||
|
||||
expect(id).toBe(121);
|
||||
});
|
||||
|
||||
// A proper motion that is not a number must not poison the comparison: NaN loses every
|
||||
// `<` it appears in, so an unguarded one lets each star past the direction test and hands
|
||||
// the planet to whichever happens to be last in the catalogue.
|
||||
it('treats an unusable proper motion as no motion rather than matching by array order', () => {
|
||||
const pointedAt = star(130, 'Pointed at', 10, 10, 5);
|
||||
const acrossTheSky = star(131, 'Across the sky', 190, -10, 5);
|
||||
|
||||
const id = resolveHostStarId(
|
||||
{ hostname: 'Unmatched', raDeg: 10, decDeg: 10, distancePc: 5, pmRaMasPerYear: NaN, pmDecMasPerYear: 0 },
|
||||
[pointedAt, acrossTheSky]
|
||||
);
|
||||
|
||||
expect(id).toBe(130);
|
||||
});
|
||||
|
||||
// Normalizing strips the dot, so `Gl 55.2` and `Gl 552` — two stars 135° apart — answer to
|
||||
// one key. A name that names both names neither: the sky has to settle it.
|
||||
it('sends a name two stars answer to back to the sky', () => {
|
||||
const gl552 = star(140, 'Gl 552', 217.0, 15.0, 14.2);
|
||||
const gl55dot2 = star(141, 'Gl 55.2', 30.0, -20.0, 23.9);
|
||||
|
||||
const id = resolveHostStarId({ hostname: 'Gl 552', raDeg: 217.0, decDeg: 15.0, distancePc: 14.2 }, [gl552, gl55dot2]);
|
||||
|
||||
expect(id).toBe(140);
|
||||
});
|
||||
|
||||
it('reuses a prebuilt name index when given one', () => {
|
||||
const nameIndex = buildStarNameIndex(FIXTURE_STARS);
|
||||
|
||||
const id = resolveHostStarId({ hostname: 'Sirius', raDeg: NaN, decDeg: NaN, distancePc: NaN }, [], nameIndex);
|
||||
|
||||
expect(id).toBe(2);
|
||||
});
|
||||
|
||||
it('returns null when no name match and no star is within tolerance', () => {
|
||||
const id = resolveHostStarId({ hostname: 'Unknown Star XYZ', raDeg: 0, decDeg: 0, distancePc: 100 }, FIXTURE_STARS, 0.5);
|
||||
|
||||
expect(id).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when there is no name match and no position is available', () => {
|
||||
const id = resolveHostStarId({ hostname: 'Unknown Star XYZ', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS, 0.5);
|
||||
|
||||
expect(id).toBeNull();
|
||||
});
|
||||
|
||||
it('picks the closest star when more than one falls within tolerance', () => {
|
||||
const stars: StarRecord[] = [
|
||||
{ id: 10, name: 'Near', x: 0, y: 0, z: 0, magnitude: 5, spectralType: 'G', colorIndex: 0.5 },
|
||||
{ id: 11, name: 'Far', x: 0.4, y: 0, z: 0, magnitude: 5, spectralType: 'G', colorIndex: 0.5 }
|
||||
];
|
||||
const nameIndex = buildStarNameIndex(stars);
|
||||
|
||||
const id = resolveHostStarId({ hostname: 'Unmatched', raDeg: 0, decDeg: 0, distancePc: 0.2 }, stars, 0.5, nameIndex);
|
||||
|
||||
expect(id).toBe(10);
|
||||
});
|
||||
|
||||
describe('missing distance column', () => {
|
||||
@@ -70,27 +190,76 @@ describe('resolveHostStarId', () => {
|
||||
// the Sun at distance 0. That shipped 127 alien planets, all seven TRAPPIST-1 worlds among
|
||||
// them, into our own solar system.
|
||||
it('does not match a host with a zero distance to the Sun', () => {
|
||||
const id = resolveHostStarId({ hostname: 'TRAPPIST-1', raDeg: 346.6, decDeg: -5.04, distancePc: 0 }, FIXTURE_STARS, 0.5);
|
||||
const id = resolveHostStarId({ hostname: 'TRAPPIST-1', raDeg: 346.6, decDeg: -5.04, distancePc: 0 }, FIXTURE_STARS);
|
||||
|
||||
expect(id).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a negative distance too', () => {
|
||||
const id = resolveHostStarId({ hostname: 'Nowhere', raDeg: 10, decDeg: 10, distancePc: -3 }, FIXTURE_STARS, 0.5);
|
||||
const id = resolveHostStarId({ hostname: 'Nowhere', raDeg: 10, decDeg: 10, distancePc: -3 }, FIXTURE_STARS);
|
||||
|
||||
expect(id).toBeNull();
|
||||
});
|
||||
|
||||
it('still matches a real host at a genuinely small distance', () => {
|
||||
const id = resolveHostStarId({ hostname: 'Unmatched', raDeg: 217.4, decDeg: -62.68, distancePc: 1.2959 }, FIXTURE_STARS, 0.5);
|
||||
const id = resolveHostStarId({ hostname: 'Unmatched', raDeg: 217.4, decDeg: -62.68, distancePc: 1.2959 }, FIXTURE_STARS);
|
||||
|
||||
expect(id).toBe(1);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const id = resolveHostStarId({ hostname: 'Sirius', raDeg: 101.3, decDeg: -16.7, distancePc: 0 }, FIXTURE_STARS, 0.5);
|
||||
const id = resolveHostStarId({ hostname: 'Sirius', raDeg: 101.3, decDeg: -16.7, distancePc: 0 }, FIXTURE_STARS);
|
||||
|
||||
expect(id).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CartesianCoordinates, distanceBetween, raDegDecDistanceToXyz } from './coordinates';
|
||||
import { ExoplanetRecord } from '../models/exoplanet.model';
|
||||
import { propagateProperMotion, raDegDecDistanceToXyz } from './coordinates';
|
||||
import { MERGE_DISTANCE_RATIO_TOLERANCE } from './star-merge';
|
||||
import { StarRecord } from '../models/star.model';
|
||||
|
||||
/** Normalizes a star name for comparison: lowercase, alphanumeric characters only. */
|
||||
@@ -12,25 +12,118 @@ export interface HostStarQuery {
|
||||
raDeg: number;
|
||||
decDeg: number;
|
||||
distancePc: number;
|
||||
}
|
||||
|
||||
/** Builds a lookup of normalized star name -> star, for fast repeated name matching. */
|
||||
export function buildStarNameIndex(stars: readonly StarRecord[]): Map<string, StarRecord> {
|
||||
return new Map(stars.map((star) => [normalizeStarName(star.name), star]));
|
||||
/** μα·cos δ in mas/yr, as the archive publishes it (`sy_pmra`); missing means unknown. */
|
||||
pmRaMasPerYear?: 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-references an exoplanet host star to the HYG star index: first by (normalized)
|
||||
* name, then by nearest-neighbour position matching within `toleranceInPc`. Returns `null`
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a lookup of normalized star name -> star, for fast repeated name matching.
|
||||
*
|
||||
* A name two stars answer to names neither: normalizing strips the dot, so `Gl 55.2` and
|
||||
* `Gl 552` — 135° apart, and 64 such groups exist in the catalogue — collide on `gl552`, and a
|
||||
* map would silently keep whichever came last. Ambiguous keys are dropped instead, which sends
|
||||
* the query to the sky, where direction settles it.
|
||||
*/
|
||||
export function buildStarNameIndex(stars: readonly StarRecord[]): Map<string, StarRecord> {
|
||||
const index = new Map<string, StarRecord>();
|
||||
const ambiguous = new Set<string>();
|
||||
for (const star of stars) {
|
||||
const key = normalizeStarName(star.name);
|
||||
if (index.has(key)) {
|
||||
ambiguous.add(key);
|
||||
} else {
|
||||
index.set(key, star);
|
||||
}
|
||||
}
|
||||
for (const key of ambiguous) {
|
||||
index.delete(key);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far, on the sky, a host may sit from a catalogue star and still be the same object —
|
||||
* expressed as a transverse offset in parsecs (separation angle × the host's distance), not as
|
||||
* an angle.
|
||||
*
|
||||
* The offset between the archive's position and ours is dominated by proper motion over an
|
||||
* epoch difference, and that is a *physical* displacement: velocity × time, the same in parsecs
|
||||
* at any distance. As an angle it is anything — Proxima's two positions are 60″ apart, a host at
|
||||
* 100 pc moves under 2″ — so a fixed angle either loses the near, fast stars or drowns the far
|
||||
* ones in neighbours. In parsecs the bound is one number: 25 years of an extreme 200 km/s
|
||||
* transverse velocity is 5·10⁻³ pc.
|
||||
*
|
||||
* Measured on the 504 hosts whose archive name matches a catalogue name outright — true pairs,
|
||||
* matched without coordinates: their transverse offset reaches 3.4·10⁻³ pc (5.0·10⁻³ before the
|
||||
* epoch straddle below) and 0.01 pc doubles that. Chance stays out of reach: shifting every
|
||||
* host a quarter of a degree finds nothing within the budget except Proxima's own entry, whose
|
||||
* budget at 1.3 pc is wider than the shift itself.
|
||||
*/
|
||||
export const HOST_TRANSVERSE_TOLERANCE_PC = 0.01;
|
||||
|
||||
/**
|
||||
* The archive does not say which epoch a row's position is for, and they are demonstrably
|
||||
* mixed: alf Tau and GJ 273 publish J2000 (the raw position sits under an arcsecond from our
|
||||
* star, and carrying it back doubles the error), HD 133131 and TOI-2459 publish Gaia DR2's J2015.5
|
||||
* (the carried-back position lands to 0.1″). DR2's, not DR3's J2016, although the archive names
|
||||
* the DR3 source: Barnard's star, Teegarden's Star, TRAPPIST-1 and 66 of the 67 archive-placed
|
||||
* stars moving over 100 mas a year equal their DR2 position to a milliarcsecond and none their
|
||||
* DR3 one. So every query is tried at both ends — as published, and carried back fifteen and a
|
||||
* 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.
|
||||
*/
|
||||
export const CATALOGUE_EPOCH = 2000.0;
|
||||
export const ARCHIVE_EPOCH = 2015.5;
|
||||
|
||||
function knownMotion(masPerYear: number | undefined): number {
|
||||
return Number.isFinite(masPerYear) ? (masPerYear as number) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-references an exoplanet host star to the star catalogue: first by (normalized) name,
|
||||
* then on the sky — the nearest star within {@link HOST_TRANSVERSE_TOLERANCE_PC} whose distance
|
||||
* does not flatly contradict the archive's ({@link MERGE_DISTANCE_RATIO_TOLERANCE}, shared with
|
||||
* the catalogue merge, which faces the same Hipparcos-vs-Gaia disagreements). Returns `null`
|
||||
* when neither approach finds a confident match, rather than guessing.
|
||||
*
|
||||
* Identity lives in the direction, exactly as in `star-merge.ts`: the previous rule — nearest
|
||||
* neighbour within half a parsec in 3D — turned into a ten-arcminute cone at 170 pc, handing
|
||||
* planets of stars our catalogue does not contain to whatever bright star floated nearest
|
||||
* (HATS-6 to HD 39500), while a 1 pc distance disagreement at 60 pc unhosted four bright
|
||||
* giants' planets whose directions matched to two arcseconds.
|
||||
*
|
||||
* `nameIndex` should be built once (via {@link buildStarNameIndex}) and reused across calls
|
||||
* when resolving many queries against the same star list.
|
||||
*/
|
||||
export function resolveHostStarId(
|
||||
query: HostStarQuery,
|
||||
stars: readonly StarRecord[],
|
||||
toleranceInPc: number,
|
||||
nameIndex: Map<string, StarRecord> = buildStarNameIndex(stars)
|
||||
): number | null {
|
||||
const byName = nameIndex.get(normalizeStarName(query.hostname));
|
||||
@@ -43,91 +136,76 @@ export function resolveHostStarId(
|
||||
}
|
||||
|
||||
// A non-positive distance is never a real measurement, and it is the specific shape a
|
||||
// missing CSV cell takes: `Number('')` is `0`, which passes the finiteness check above and
|
||||
// then places the host exactly at the origin — where it matches the Sun at distance 0 and
|
||||
// hands an alien planet to our own solar system.
|
||||
// missing CSV cell takes: `Number('')` is `0`. Without a believable distance there is no
|
||||
// transverse budget and no ratio test, so the position cannot speak.
|
||||
if (query.distancePc <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostPosition = raDegDecDistanceToXyz(query.raDeg, query.decDeg, query.distancePc);
|
||||
return findNearestStarWithin(hostPosition, stars, toleranceInPc);
|
||||
}
|
||||
|
||||
function findNearestStarWithin(position: CartesianCoordinates, stars: readonly StarRecord[], toleranceInPc: number): number | null {
|
||||
let closest: { id: number; distance: number } | null = null;
|
||||
const published = raDegDecDistanceToXyz(query.raDeg, query.decDeg, 1);
|
||||
const carriedBack = propagateProperMotion(
|
||||
query.raDeg,
|
||||
query.decDeg,
|
||||
// A proper motion that is not a number must read as "stands still", not poison the
|
||||
// comparison: one NaN makes every star's cosine NaN, and `NaN < min` is false, so every
|
||||
// star would pass the direction test and the last one in array order would win.
|
||||
knownMotion(query.pmRaMasPerYear),
|
||||
knownMotion(query.pmDecMasPerYear),
|
||||
CATALOGUE_EPOCH - ARCHIVE_EPOCH
|
||||
);
|
||||
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));
|
||||
let best: StarRecord | null = null;
|
||||
let bestCosine = -2;
|
||||
for (const star of stars) {
|
||||
const distance = distanceBetween(position, star);
|
||||
if (distance <= toleranceInPc && (!closest || distance < closest.distance)) {
|
||||
closest = { id: star.id, distance };
|
||||
const starDistance = Math.hypot(star.x, star.y, star.z);
|
||||
// The Sun sits at the origin and has no direction to compare; every real host is elsewhere.
|
||||
if (starDistance === 0) {
|
||||
continue;
|
||||
}
|
||||
const cosine =
|
||||
Math.max(
|
||||
star.x * published.x + star.y * published.y + star.z * published.z,
|
||||
star.x * carried.x + star.y * carried.y + star.z * carried.z
|
||||
) / starDistance;
|
||||
if (cosine < minCosine || cosine <= bestCosine) {
|
||||
continue;
|
||||
}
|
||||
if (!distancesAgree(query.distancePc, starDistance) && !(parallaxPc > 0 && distancesAgree(parallaxPc, starDistance))) {
|
||||
continue;
|
||||
}
|
||||
best = star;
|
||||
bestCosine = cosine;
|
||||
}
|
||||
|
||||
return closest ? closest.id : null;
|
||||
return best ? best.id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-resolves every exoplanet's host star against a star catalogue.
|
||||
*
|
||||
* The cross-reference is a *derived* fact: it depends as much on which stars were loaded as on
|
||||
* the archive itself. When the catalogue reached 50 pc, 388 of the archive's 4735 named hosts
|
||||
* found a match and the other 4347 were carried and never drawn — not because their planets are
|
||||
* unknown, but because their star was out of range. Widening the catalogue rescues some of them,
|
||||
* and until the host coordinates were stored alongside each planet that meant re-downloading an
|
||||
* archive which is not always reachable.
|
||||
*
|
||||
* Records written before those coordinates were kept can still be matched *by name*, which needs
|
||||
* no coordinates at all — and that alone is worth doing, because a wider catalogue contains more
|
||||
* names. What such a record cannot do is disprove its existing match: a name miss means only
|
||||
* that the name missed, not that the star is absent. So those are upgraded where a match is
|
||||
* found and left alone otherwise, while records that do carry coordinates take the new result
|
||||
* outright, match or no match.
|
||||
* 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;
|
||||
|
||||
/** A host must sit within this many parsecs of a catalogue star to count as the same object. */
|
||||
export const HOST_MATCH_TOLERANCE_PC = 2;
|
||||
|
||||
export interface RematchSummary {
|
||||
total: number;
|
||||
/** Records carrying host coordinates, and therefore eligible to be re-matched in full. */
|
||||
resolvable: number;
|
||||
matched: number;
|
||||
gained: number;
|
||||
lost: number;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
export function rematchHostStars(exoplanets: ExoplanetRecord[], stars: readonly StarRecord[]): RematchSummary {
|
||||
const nameIndex = buildStarNameIndex(stars);
|
||||
const summary: RematchSummary = { total: exoplanets.length, resolvable: 0, matched: 0, gained: 0, lost: 0 };
|
||||
|
||||
for (const exoplanet of exoplanets) {
|
||||
const { hostRaDeg, hostDecDeg, hostDistancePc } = exoplanet;
|
||||
const positioned = hostRaDeg !== undefined && hostDecDeg !== undefined && hostDistancePc !== undefined;
|
||||
if (positioned) {
|
||||
summary.resolvable++;
|
||||
let offset = hash % ARCHIVE_ID_RANGE;
|
||||
while (taken.has(ARCHIVE_ID_BASE + offset)) {
|
||||
offset = (offset + 1) % ARCHIVE_ID_RANGE;
|
||||
}
|
||||
|
||||
const previous = exoplanet.hostStarId;
|
||||
// With no coordinates the query still carries the host's name, and `resolveHostStarId` tries
|
||||
// that first; the positional fallback simply declines to run on non-finite coordinates.
|
||||
const resolved = resolveHostStarId(
|
||||
{ hostname: exoplanet.hostStarName, raDeg: hostRaDeg ?? Number.NaN, decDeg: hostDecDeg ?? Number.NaN, distancePc: hostDistancePc ?? Number.NaN },
|
||||
stars,
|
||||
HOST_MATCH_TOLERANCE_PC,
|
||||
nameIndex
|
||||
);
|
||||
|
||||
exoplanet.hostStarId = positioned ? resolved : (resolved ?? previous);
|
||||
if (exoplanet.hostStarId !== null) {
|
||||
summary.matched++;
|
||||
}
|
||||
if (previous === null && exoplanet.hostStarId !== null) {
|
||||
summary.gained++;
|
||||
} else if (previous !== null && exoplanet.hostStarId === null) {
|
||||
summary.lost++;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
return ARCHIVE_ID_BASE + offset;
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ExoplanetRecord } from '../models/exoplanet.model';
|
||||
import { StarRecord } from '../models/star.model';
|
||||
import { rematchHostStars } from './host-star-matching';
|
||||
|
||||
/** Two catalogue stars, one of which is only present in the wider of the two catalogues. */
|
||||
const NEARBY: StarRecord = { id: 100, name: 'Gl 357', x: 9, y: 0, z: 0, magnitude: 10.9, spectralType: 'K', colorIndex: 1.4 };
|
||||
const DISTANT: StarRecord = { id: 200, name: 'HD 33844', x: 0, y: 120, z: 0, magnitude: 7.7, spectralType: 'K0', colorIndex: 1.0 };
|
||||
|
||||
const NARROW_CATALOGUE = [NEARBY];
|
||||
const WIDE_CATALOGUE = [NEARBY, DISTANT];
|
||||
|
||||
function planet(overrides: Partial<ExoplanetRecord> = {}): ExoplanetRecord {
|
||||
return { id: 'p', hostStarId: null, hostStarName: 'HD 33844', name: 'HD 33844 b', orbit: { semiMajorAxisAu: 1 }, ...overrides };
|
||||
}
|
||||
|
||||
describe('rematchHostStars', () => {
|
||||
it('rescues a host that the wider catalogue now contains, by name alone', () => {
|
||||
// The whole point: the cross-reference is a fact about the catalogue as much as about the
|
||||
// archive, so widening one ought to resolve hosts the other already knew about.
|
||||
const planets = [planet()];
|
||||
const summary = rematchHostStars(planets, WIDE_CATALOGUE);
|
||||
|
||||
expect(planets[0].hostStarId).toBe(DISTANT.id);
|
||||
expect(summary.gained).toBe(1);
|
||||
expect(summary.matched).toBe(1);
|
||||
});
|
||||
|
||||
it('needs no coordinates to do it', () => {
|
||||
// Which matters, because the shipped records were written before coordinates were kept.
|
||||
const planets = [planet()];
|
||||
expect(planets[0].hostRaDeg).toBeUndefined();
|
||||
rematchHostStars(planets, WIDE_CATALOGUE);
|
||||
expect(planets[0].hostStarId).toBe(DISTANT.id);
|
||||
});
|
||||
|
||||
it('will not clear an existing match on a name miss when it has no coordinates', () => {
|
||||
// A name miss says the name missed, not that the star is absent — and the earlier match may
|
||||
// have been positional, from data this record no longer carries.
|
||||
const planets = [planet({ hostStarId: 999, hostStarName: 'Some Survey Designation' })];
|
||||
const summary = rematchHostStars(planets, WIDE_CATALOGUE);
|
||||
|
||||
expect(planets[0].hostStarId).toBe(999);
|
||||
expect(summary.lost).toBe(0);
|
||||
expect(summary.matched).toBe(1);
|
||||
});
|
||||
|
||||
it('takes the new answer outright when the record does carry coordinates', () => {
|
||||
// With coordinates the match can be redone in full, so its result is authoritative — a host
|
||||
// that no longer resolves is cleared rather than left pointing at a star that may be gone.
|
||||
const planets = [planet({ hostStarId: 999, hostStarName: 'Nowhere', hostRaDeg: 10, hostDecDeg: 10, hostDistancePc: 500 })];
|
||||
const summary = rematchHostStars(planets, WIDE_CATALOGUE);
|
||||
|
||||
expect(planets[0].hostStarId).toBeNull();
|
||||
expect(summary.resolvable).toBe(1);
|
||||
expect(summary.lost).toBe(1);
|
||||
});
|
||||
|
||||
it('matches a positioned host to the catalogue star at its coordinates', () => {
|
||||
const planets = [planet({ hostStarName: 'unlisted alias', hostRaDeg: 90, hostDecDeg: 0, hostDistancePc: 120 })];
|
||||
rematchHostStars(planets, WIDE_CATALOGUE);
|
||||
expect(planets[0].hostStarId).toBe(DISTANT.id);
|
||||
});
|
||||
|
||||
it('leaves a host that neither catalogue contains unmatched', () => {
|
||||
const planets = [planet()];
|
||||
const summary = rematchHostStars(planets, NARROW_CATALOGUE);
|
||||
|
||||
expect(planets[0].hostStarId).toBeNull();
|
||||
expect(summary.matched).toBe(0);
|
||||
expect(summary.gained).toBe(0);
|
||||
});
|
||||
|
||||
it('counts every record it was given', () => {
|
||||
const planets = [planet(), planet({ id: 'q', hostStarName: 'Gl 357' }), planet({ id: 'r', hostStarName: 'nobody' })];
|
||||
const summary = rematchHostStars(planets, WIDE_CATALOGUE);
|
||||
|
||||
expect(summary.total).toBe(3);
|
||||
expect(summary.matched).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { jumpLinkSegments, minimumRangeBetween, routeBetween } from './jump-links';
|
||||
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
|
||||
|
||||
/** Stars a parsec apart along x, so a chain's length is the number of hops it takes. */
|
||||
function chain(count: number): StarNeighbourhood {
|
||||
return new StarNeighbourhood(Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 })));
|
||||
}
|
||||
|
||||
function index(points: StarPoint[]): StarNeighbourhood {
|
||||
return new StarNeighbourhood(points);
|
||||
}
|
||||
|
||||
describe('routeBetween', () => {
|
||||
it('walks the chain a hop at a time when that is all the range allows', () => {
|
||||
const { route } = routeBetween(chain(5), 0, 4, 1.5);
|
||||
|
||||
expect(route?.stars).toEqual([0, 1, 2, 3, 4]);
|
||||
expect(route?.totalPc).toBeCloseTo(4);
|
||||
expect(route?.longestHopPc).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
it('goes straight there when the range reaches, however many stars lie between', () => {
|
||||
// The direct crossing is never longer than a chain through anything — Euclid says so — so a
|
||||
// range that covers it makes it the answer, and the stars in between are just scenery.
|
||||
const { route } = routeBetween(chain(5), 0, 4, 5);
|
||||
|
||||
expect(route?.stars).toEqual([0, 4]);
|
||||
expect(route?.totalPc).toBeCloseTo(4);
|
||||
});
|
||||
|
||||
it('picks the shorter of two ways round when neither is a straight line', () => {
|
||||
// 0 to 3 is 10 pc, out of a 6 pc range. Two ways round, both inside it: through 1, barely
|
||||
// off the line, or through 2, well off it. Shorter is what "the way there" means.
|
||||
const { route } = routeBetween(
|
||||
index([
|
||||
{ id: 0, x: 0, y: 0, z: 0 },
|
||||
{ id: 1, x: 5, y: 0.5, z: 0 },
|
||||
{ id: 2, x: 5, y: 3, z: 0 },
|
||||
{ id: 3, x: 10, y: 0, z: 0 }
|
||||
]),
|
||||
0,
|
||||
3,
|
||||
6
|
||||
);
|
||||
|
||||
expect(route?.stars).toEqual([0, 1, 3]);
|
||||
expect(route?.totalPc).toBeCloseTo(10.05, 1);
|
||||
});
|
||||
|
||||
it('finds nothing across a gap wider than the range', () => {
|
||||
const split = index([
|
||||
{ id: 0, x: 0, y: 0, z: 0 },
|
||||
{ id: 1, x: 1, y: 0, z: 0 },
|
||||
{ id: 2, x: 20, y: 0, z: 0 }
|
||||
]);
|
||||
|
||||
expect(routeBetween(split, 0, 2, 5)).toEqual({ route: null, gaveUp: false });
|
||||
});
|
||||
|
||||
it('answers nothing for a star that is not there, or for going nowhere', () => {
|
||||
const line = chain(3);
|
||||
|
||||
expect(routeBetween(line, 0, 0, 2).route).toBeNull();
|
||||
expect(routeBetween(line, 0, 99, 2).route).toBeNull();
|
||||
expect(routeBetween(line, 0, 2, 0).route).toBeNull();
|
||||
});
|
||||
|
||||
it('reports the longest hop, which is what the range has to cover', () => {
|
||||
const { route } = routeBetween(
|
||||
index([
|
||||
{ id: 0, x: 0, y: 0, z: 0 },
|
||||
{ id: 1, x: 1, y: 0, z: 0 },
|
||||
{ id: 2, x: 5, y: 0, z: 0 }
|
||||
]),
|
||||
0,
|
||||
2,
|
||||
4
|
||||
);
|
||||
|
||||
expect(route?.longestHopPc).toBeCloseTo(4);
|
||||
});
|
||||
|
||||
it('says it gave up rather than that there is no chain, once it has spent its budget', { timeout: 30_000 }, () => {
|
||||
// Nothing reaches the island, but the crowd around the departure is larger than the budget, so
|
||||
// the search stops without having looked everywhere the range reaches. Read as "no chain", that
|
||||
// is a confident wrong answer — and the range search downstream would build on it.
|
||||
// Cells sized for the range asked of them, as the real catalogue's are: a search that settles
|
||||
// 40 000 stars scans every cell it touches 40 000 times.
|
||||
const search = routeBetween(knotAndChain(1.5), 0, ISLAND, 1.5);
|
||||
|
||||
expect(search.route).toBeNull();
|
||||
expect(search.gaveUp).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a dead end proved with the last star of the budget as a dead end, not a give-up', () => {
|
||||
// Exactly the budget's worth of stars reach each other, and the destination is not among them.
|
||||
// The search does look everywhere the range reaches, so "no chain" is what it found — but the
|
||||
// set is full at the end of it, and a budget read off the settled count says it gave up.
|
||||
const search = routeBetween(budgetExactly(), 0, BUDGET_ISLAND, 1.5);
|
||||
|
||||
expect(search).toEqual({ route: null, gaveUp: false });
|
||||
});
|
||||
|
||||
it('heads for the destination rather than exhausting a dense knot around the departure', () => {
|
||||
// The Gaia catalogue in miniature: a crowd around the departure, larger than the search's
|
||||
// budget, with the only way on a thin chain leading out of it. A search widening evenly from
|
||||
// the departure spends the budget on the crowd and never reaches the chain's far end.
|
||||
const { route } = routeBetween(knotAndChain(), 0, CHAIN_END, 1.5);
|
||||
|
||||
expect(route).not.toBeNull();
|
||||
expect(route!.stars[route!.stars.length - 1]).toBe(CHAIN_END);
|
||||
expect(route!.longestHopPc).toBeLessThanOrEqual(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 45 000 stars scattered through the 30 pc cube around the origin, twenty times the density around
|
||||
* the real Sun and more than a search's budget, with a chain a parsec a hop running along x from
|
||||
* the origin out through the crowd and on to 75 pc — and one star at 500 pc that nothing reaches.
|
||||
*/
|
||||
const CHAIN_END = 75;
|
||||
const ISLAND = 999;
|
||||
function knotAndChain(cellSizePc?: number): StarNeighbourhood {
|
||||
let seed = 7;
|
||||
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 30 - 15;
|
||||
const knot: StarPoint[] = Array.from({ length: 45000 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
|
||||
const chainOut: StarPoint[] = Array.from({ length: CHAIN_END }, (_, i) => ({ id: i + 1, x: i + 1, y: 0, z: 0 }));
|
||||
return new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...knot, ...chainOut, { id: ISLAND, x: 500, y: 0, z: 0 }], cellSizePc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exactly a search's budget of stars that reach one another — 40 000 a parsec apart along x, which
|
||||
* a 1.5 pc range walks end to end — and one 500 pc off that line, which nothing reaches. The dead
|
||||
* end is real and the search proves it, with the last star it is allowed.
|
||||
*
|
||||
* A line rather than a crowd because the count has to be exact: a random cloud dense enough to
|
||||
* connect leaves clumps the departure never reaches, and 39 662 of 40 000 settled is a budget that
|
||||
* was never spent.
|
||||
*/
|
||||
const BUDGET_ISLAND = 99_999;
|
||||
function budgetExactly(): StarNeighbourhood {
|
||||
const line: StarPoint[] = Array.from({ length: 40_000 }, (_, i) => ({ id: i, x: i, y: 0, z: 0 }));
|
||||
return new StarNeighbourhood([...line, { id: BUDGET_ISLAND, x: 0, y: 500, z: 0 }], 1.5);
|
||||
}
|
||||
|
||||
/**
|
||||
* 45 000 stars in a 10 pc cube — dense enough to stay one connected piece at half a parsec, where
|
||||
* walking it costs more than a search's budget — with a chain a parsec a hop leaving its edge for
|
||||
* 30 pc. Its cells are sized for the ranges asked of it, as the real catalogue's are for its own.
|
||||
*/
|
||||
const CROWD_CHAIN_END = 25;
|
||||
const CROWD_ISLAND = 999999;
|
||||
function crowdedKnot(): StarNeighbourhood {
|
||||
let seed = 11;
|
||||
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 10 - 5;
|
||||
const knot: StarPoint[] = Array.from({ length: 45000 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
|
||||
const chainOut: StarPoint[] = Array.from({ length: CROWD_CHAIN_END }, (_, i) => ({ id: i + 1, x: 5 + i + 1, y: 0, z: 0 }));
|
||||
// One star nothing reaches, for the questions that have no answer.
|
||||
return new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...knot, ...chainOut, { id: CROWD_ISLAND, x: 500, y: 0, z: 0 }], 0.25);
|
||||
}
|
||||
|
||||
describe('minimumRangeBetween', () => {
|
||||
it('works out the range past a dense knot around the departure', () => {
|
||||
// Past the crowd the chain's hops of a parsec are the only way on, so a parsec is the
|
||||
// answer, to the half-step the panel rounds up to.
|
||||
expect(minimumRangeBetween(knotAndChain(), 0, CHAIN_END, 8).rangePc).toBeCloseTo(1, 1);
|
||||
});
|
||||
|
||||
it('stops bisecting where a search gave up, and hands back a range that does work', { timeout: 30_000 }, () => {
|
||||
// Below the chain's own hop of a parsec, the crowd is still one connected piece and larger than
|
||||
// the budget, so those probes give up. Reading a give-up as "no chain at this range" is what
|
||||
// used to report ranges up to 29% wider than needed, and went on paying for probes whose
|
||||
// answers it could not use; the answer now is the narrowest range a chain was found at.
|
||||
const knot = crowdedKnot();
|
||||
|
||||
const needed = minimumRangeBetween(knot, 0, CROWD_CHAIN_END, 1.2);
|
||||
|
||||
expect(needed.least).toBe(false);
|
||||
expect(needed.rangePc).not.toBeNull();
|
||||
expect(routeBetween(knot, 0, CROWD_CHAIN_END, needed.rangePc!).route).not.toBeNull();
|
||||
// Narrower than the ceiling's own route, too: stopping before the bisection has found a range
|
||||
// of its own hands back the ceiling, which is the control's maximum — the question, not an answer.
|
||||
expect(needed.rangePc!).toBeLessThan(routeBetween(knot, 0, CROWD_CHAIN_END, 1.2).route!.longestHopPc);
|
||||
});
|
||||
|
||||
it('names the shortest range that opens a way through', () => {
|
||||
// Hops of 1 and 4: no range under 4 connects them, and 4 exactly does.
|
||||
const stepped = index([
|
||||
{ id: 0, x: 0, y: 0, z: 0 },
|
||||
{ id: 1, x: 1, y: 0, z: 0 },
|
||||
{ id: 2, x: 5, y: 0, z: 0 }
|
||||
]);
|
||||
|
||||
expect(minimumRangeBetween(stepped, 0, 2, 50)).toEqual({ rangePc: expect.closeTo(4) as number, least: true });
|
||||
expect(routeBetween(stepped, 0, 2, 4).route).not.toBeNull();
|
||||
expect(routeBetween(stepped, 0, 2, 3.99).route).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers a longer way whose worst hop is shorter, since that is what the range pays for', () => {
|
||||
// Direct: one hop of 10. Round: three hops of at most 4. The range only has to cover 4.
|
||||
const both = index([
|
||||
{ id: 0, x: 0, y: 0, z: 0 },
|
||||
{ id: 1, x: 0, y: 4, z: 0 },
|
||||
{ id: 2, x: 6, y: 7, z: 0 },
|
||||
{ id: 3, x: 10, y: 0, z: 0 }
|
||||
]);
|
||||
|
||||
const needed = minimumRangeBetween(both, 0, 3, 50);
|
||||
|
||||
expect(needed.rangePc).toBeLessThan(10);
|
||||
expect(routeBetween(both, 0, 3, needed.rangePc!).route).not.toBeNull();
|
||||
});
|
||||
|
||||
it('claims nothing about a ceiling its own search gave up on', () => {
|
||||
// Nothing reaches the island at any range here, but the crowd spends the budget first, so the
|
||||
// widest search proves nothing — and neither does the null it hands back.
|
||||
const needed = minimumRangeBetween(crowdedKnot(), 0, CROWD_ISLAND, 0.5);
|
||||
|
||||
expect(needed).toEqual({ rangePc: null, least: false });
|
||||
});
|
||||
|
||||
it('finds nothing when even the ceiling does not reach', () => {
|
||||
const split = index([
|
||||
{ id: 0, x: 0, y: 0, z: 0 },
|
||||
{ id: 1, x: 100, y: 0, z: 0 }
|
||||
]);
|
||||
|
||||
expect(minimumRangeBetween(split, 0, 1, 50)).toEqual({ rangePc: null, least: true });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The links a segment buffer draws, as unordered pairs of star ids, read back from where each end
|
||||
* sits. Positions are compared as the float32 the buffer holds.
|
||||
*/
|
||||
function linksDrawn(segments: Float32Array, points: readonly StarPoint[]): string[] {
|
||||
const idAt = new Map(points.map((point) => [[point.x, point.y, point.z].map(Math.fround).join(), point.id]));
|
||||
const links: string[] = [];
|
||||
for (let at = 0; at < segments.length; at += 6) {
|
||||
const a = idAt.get(Array.from(segments.subarray(at, at + 3)).join())!;
|
||||
const b = idAt.get(Array.from(segments.subarray(at + 3, at + 6)).join())!;
|
||||
links.push(a < b ? `${a}-${b}` : `${b}-${a}`);
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a budget should keep, worked out the slow way: every link sorted by how near its nearer end
|
||||
* is to the centre, then taken until one does not fit. Lengths and distances as the float32 buffer
|
||||
* holds them.
|
||||
*/
|
||||
function nearestFirst(points: readonly StarPoint[], rangePc: number, centre: { x: number; y: number; z: number }, lengthPc: number): string[] {
|
||||
const all = jumpLinkSegments(index([...points]), rangePc);
|
||||
const links = Array.from({ length: all.length / 6 }, (_, link) => {
|
||||
const v = Array.from(all.subarray(link * 6, link * 6 + 6));
|
||||
const nearer = Math.fround(Math.sqrt(Math.min((v[0] - centre.x) ** 2 + (v[1] - centre.y) ** 2 + (v[2] - centre.z) ** 2, (v[3] - centre.x) ** 2 + (v[4] - centre.y) ** 2 + (v[5] - centre.z) ** 2)));
|
||||
return { link, nearer, length: Math.fround(Math.hypot(v[3] - v[0], v[4] - v[1], v[5] - v[2])), key: linksDrawn(all.subarray(link * 6, link * 6 + 6), points)[0] };
|
||||
}).sort((a, b) => a.nearer - b.nearer || a.link - b.link);
|
||||
const kept: string[] = [];
|
||||
let total = 0;
|
||||
for (const { length, key } of links) {
|
||||
if (total + length > lengthPc) {
|
||||
break;
|
||||
}
|
||||
total += length;
|
||||
kept.push(key);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** Stars a parsec apart along x, as points, for reading a segment buffer back. */
|
||||
function chainPoints(count: number): StarPoint[] {
|
||||
return Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 }));
|
||||
}
|
||||
|
||||
describe('jumpLinkSegments', () => {
|
||||
it('draws each pair once, not once from either end', () => {
|
||||
const segments = jumpLinkSegments(chain(4), 1.5);
|
||||
|
||||
expect(linksDrawn(segments, chainPoints(4)).sort()).toEqual(['0-1', '1-2', '2-3']);
|
||||
});
|
||||
|
||||
it('puts both ends of every link where its stars are', () => {
|
||||
const segments = jumpLinkSegments(chain(3), 2.5);
|
||||
|
||||
expect(segments).toHaveLength(3 * 6);
|
||||
expect(linksDrawn(segments, chainPoints(3)).sort()).toEqual(['0-1', '0-2', '1-2']);
|
||||
});
|
||||
|
||||
it('draws nothing at no range', () => {
|
||||
expect(jumpLinkSegments(chain(4), 0)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps the links nearest the centre first, for as much length as the budget holds', () => {
|
||||
// A parsec apart from 0 to 20, the centre at 10.3. By nearer end: 9-10 and 10-11 (0.3 away),
|
||||
// then 11-12 (0.7), then 8-9 (1.3). Three parsecs of them fit in 3.5; a fourth would not.
|
||||
const budget = { centre: { x: 10.3, y: 0, z: 0 }, lengthPc: 3.5 };
|
||||
|
||||
const segments = jumpLinkSegments(chain(21), 1.5, budget);
|
||||
|
||||
expect(linksDrawn(segments, chainPoints(21)).sort()).toEqual(['10-11', '11-12', '9-10']);
|
||||
expect(segments.buffer.byteLength).toBe(segments.byteLength);
|
||||
});
|
||||
|
||||
it('keeps exactly the links a full nearest-first sort would, without sorting them all', () => {
|
||||
let seed = 7;
|
||||
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 40 - 20;
|
||||
const points: StarPoint[] = Array.from({ length: 600 }, (_, id) => ({ id, x: random(), y: random(), z: random() }));
|
||||
const centre = { x: 3, y: -2, z: 1 };
|
||||
|
||||
for (const lengthPc of [0, 5, 60, 900, 4000, 1e9]) {
|
||||
expect(linksDrawn(jumpLinkSegments(index(points), 4, { centre, lengthPc }), points).sort()).toEqual(nearestFirst(points, 4, centre, lengthPc).sort());
|
||||
}
|
||||
});
|
||||
|
||||
it('sorts the distance band the budget runs out in, and stops at the first link there that does not fit', () => {
|
||||
// One pair 4 kpc out makes each band about a parsec deep, so dozens of short links near the
|
||||
// centre share the band the budget ends in, in whatever order the grid walks them.
|
||||
let seed = 3;
|
||||
const random = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
|
||||
const points: StarPoint[] = [{ id: 0, x: 4000, y: 0, z: 0 }, { id: 1, x: 4000.03, y: 0, z: 0 }];
|
||||
for (let pair = 0; pair < 40; pair++) {
|
||||
const r = 0.05 + random() * 0.9;
|
||||
const theta = random() * Math.PI * 2;
|
||||
const x = r * Math.cos(theta);
|
||||
const y = r * Math.sin(theta);
|
||||
points.push({ id: 2 + pair * 2, x, y, z: 0 }, { id: 3 + pair * 2, x, y, z: 0.005 + random() * 0.04 });
|
||||
}
|
||||
const centre = { x: 0, y: 0, z: 0 };
|
||||
|
||||
for (const lengthPc of [0.1, 0.3, 0.5]) {
|
||||
expect(linksDrawn(jumpLinkSegments(index(points), 0.05, { centre, lengthPc }), points).sort()).toEqual(nearestFirst(points, 0.05, centre, lengthPc).sort());
|
||||
}
|
||||
});
|
||||
|
||||
it('counts the budget in parsecs of link, not in links', () => {
|
||||
// Stars at 0, 1 and 3: a 2 pc link nearest the centre, then a 1 pc one. Two and a half parsecs
|
||||
// hold the first and not both, though two links would fit a count of two and a half.
|
||||
const points: StarPoint[] = [{ id: 0, x: 0, y: 0, z: 0 }, { id: 1, x: 1, y: 0, z: 0 }, { id: 2, x: 3, y: 0, z: 0 }];
|
||||
|
||||
const segments = jumpLinkSegments(index(points), 2.5, { centre: { x: 3, y: 0, z: 0 }, lengthPc: 2.5 });
|
||||
|
||||
expect(linksDrawn(segments, points)).toEqual(['1-2']);
|
||||
});
|
||||
|
||||
it('grows past its first buffer without losing a link', () => {
|
||||
// 5 000 stars a tenth of a parsec apart, ten neighbours each way in range: some 50 000 links, far past
|
||||
// the 4 096 the buffer starts with, so it has to grow several times.
|
||||
const count = 5000;
|
||||
const line = new StarNeighbourhood(Array.from({ length: count }, (_, i) => ({ id: i, x: i / 10, y: 0, z: 0 })));
|
||||
// 1.05 rather than 1: the tenth neighbour sits at 1.0, which float steps of a tenth put either side of it.
|
||||
const segments = jumpLinkSegments(line, 1.05);
|
||||
|
||||
let expected = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
expected += Math.min(10, count - 1 - i);
|
||||
}
|
||||
expect(segments.length / 6).toBe(expected);
|
||||
expect(segments.buffer.byteLength).toBe(segments.byteLength);
|
||||
});
|
||||
|
||||
it('agrees with every route it makes possible', () => {
|
||||
// The graph drawn and the graph walked have to be the same graph, or the map shows a way
|
||||
// the route cannot take.
|
||||
let seed = 11;
|
||||
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 30 - 15;
|
||||
const points: StarPoint[] = Array.from({ length: 120 }, (_, id) => ({ id, x: random(), y: random(), z: random() }));
|
||||
const cloud = index(points);
|
||||
// 9 rather than 6: at 6 this cloud falls into pieces and 0 never reaches 119, which an
|
||||
// earlier version of this test hid by only checking the route it happened to find.
|
||||
const range = 9;
|
||||
|
||||
const drawn = new Set(linksDrawn(jumpLinkSegments(cloud, range), points));
|
||||
|
||||
const { route } = routeBetween(cloud, 0, 119, range);
|
||||
// Asserted, not guarded: a skipped body would let the two disagree unnoticed.
|
||||
expect(route).not.toBeNull();
|
||||
expect(route!.stars.length).toBeGreaterThan(2);
|
||||
for (let i = 1; i < route!.stars.length; i++) {
|
||||
const [a, b] = [route!.stars[i - 1], route!.stars[i]].sort((x, y) => x - y);
|
||||
expect(drawn.has(`${a}-${b}`)).toBe(true);
|
||||
}
|
||||
expect(drawn.size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* Which stars are within reach of which, and how to get from one to another through them.
|
||||
*
|
||||
* A "jump link" is nothing more than a pair of catalogued stars closer together than some
|
||||
* chosen range. It is not a feature of space — there are no corridors out there — it is a
|
||||
* question asked of the catalogue: if a crossing of at most this far can be made, which stars
|
||||
* can be strung together, and what is the shortest chain from here to there.
|
||||
*
|
||||
* Two facts about the catalogue shape everything here, and both are worth stating because the
|
||||
* answers look like defects otherwise. It is magnitude-limited, so it is dense around the Sun
|
||||
* and thins with distance: within 50 pc a 3 pc range links 99% of it into one piece, while over
|
||||
* the whole 250 pc reach the same range leaves most stars alone. And a gap in it is a gap in
|
||||
* what has been catalogued, not in what is there. So a route that cannot be found is a
|
||||
* statement about the map, and `minimumRangeBetween` exists to say which.
|
||||
*/
|
||||
|
||||
import { StarNeighbourhood } from './star-neighbourhood';
|
||||
|
||||
/** What a search found, and whether it looked everywhere the range reaches before answering. */
|
||||
export interface RouteSearch {
|
||||
readonly route: Route | null;
|
||||
/** True when the search spent its budget: "no route" then means "gave up", not "there is none". */
|
||||
readonly gaveUp: boolean;
|
||||
}
|
||||
|
||||
/** A range that opens a route, and whether anything shorter was actually ruled out. */
|
||||
export interface RangeSearch {
|
||||
/** A range a chain was found at, or `null` where none was found up to the ceiling. */
|
||||
readonly rangePc: number | null;
|
||||
/** True when every shorter range was searched to exhaustion, so this is the least that works. */
|
||||
readonly least: boolean;
|
||||
}
|
||||
|
||||
/** A chain of stars from one to another, each hop within the range that was asked for. */
|
||||
export interface Route {
|
||||
/** Star ids, departure first and destination last. One hop is two ids. */
|
||||
readonly stars: readonly number[];
|
||||
/** The sum of the hops, in parsecs. */
|
||||
readonly totalPc: number;
|
||||
/**
|
||||
* The longest single hop. The range has to cover this and nothing wider, so it is what a
|
||||
* reader checks a route against — and it is the figure `minimumRangeBetween` minimises.
|
||||
*/
|
||||
readonly longestHopPc: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cap on how much of the catalogue one search may walk, so a hopeless question cannot run for
|
||||
* ever. It is a budget, not a verdict: a search that spends it has proved nothing, and says so
|
||||
* through {@link RouteSearch.gaveUp}.
|
||||
*
|
||||
* Sized against the catalogue actually shipped rather than against the longest route. At 20 000 a
|
||||
* search from the Sun to HD 120147 (136 pc) in jumps of 5 pc gave up, though the chain it wanted,
|
||||
* 50 jumps, is there to be found; a star at 170 pc needed 58. Both are found at this budget. The
|
||||
* cost is paid by questions with no answer, which walk the whole of it: from the Sun to the
|
||||
* farthest star at 8 pc, 0.7 s at 20 000 against 2.1 s here, in the worker.
|
||||
*/
|
||||
const MAX_VISITED = 40000;
|
||||
|
||||
/**
|
||||
* How close to the true minimum `minimumRangeBetween` works a range out: half the Routes panel's
|
||||
* own step, which it rounds up to. Never at the cost of an answer that fails to open a route,
|
||||
* since the figure it reports is always the longest hop of a route actually found.
|
||||
*/
|
||||
const RANGE_RESOLUTION_PC = 0.05;
|
||||
|
||||
/**
|
||||
* How many of `minimumRangeBetween`'s probes may give up, once it has a range of its own, before it
|
||||
* answers with what it has — and how many before it has one.
|
||||
*
|
||||
* A probe that finds a route is quick — it heads straight for the destination — while one that
|
||||
* gives up walks the whole search budget, about two seconds on the real catalogue. Those are also
|
||||
* the probes that buy the least: they cannot rule anything out. Two of them is the difference
|
||||
* between an answer of 7.96 pc in half a second and 5.76 pc in seventeen, for a star at 236 pc; it
|
||||
* lands on 5.97 pc in five.
|
||||
*
|
||||
* Until a probe succeeds there is nothing to answer with but the ceiling route's own longest hop,
|
||||
* which is the control's maximum, so the bound is looser there — but a bound, since the search is
|
||||
* one the panel waits on: five probes, ten seconds, rather than the resolution's own eight.
|
||||
*/
|
||||
const MAX_RANGE_GIVE_UPS = 2;
|
||||
const MAX_UNEARNED_GIVE_UPS = 5;
|
||||
|
||||
/** A binary min-heap of star ids by priority. Duplicates are allowed; stale ones are skipped on the way out. */
|
||||
class Frontier {
|
||||
private readonly ids: number[] = [];
|
||||
private readonly priorities: number[] = [];
|
||||
|
||||
get size(): number {
|
||||
return this.ids.length;
|
||||
}
|
||||
|
||||
push(id: number, priority: number): void {
|
||||
let at = this.ids.length;
|
||||
this.ids.push(id);
|
||||
this.priorities.push(priority);
|
||||
while (at > 0) {
|
||||
const parent = (at - 1) >> 1;
|
||||
if (this.priorities[parent] <= priority) {
|
||||
break;
|
||||
}
|
||||
this.ids[at] = this.ids[parent];
|
||||
this.priorities[at] = this.priorities[parent];
|
||||
at = parent;
|
||||
}
|
||||
this.ids[at] = id;
|
||||
this.priorities[at] = priority;
|
||||
}
|
||||
|
||||
/** The id with the lowest priority, taken out. Only called while `size` is not zero. */
|
||||
pop(): number {
|
||||
const top = this.ids[0];
|
||||
const lastId = this.ids.pop()!;
|
||||
const lastPriority = this.priorities.pop()!;
|
||||
const count = this.ids.length;
|
||||
if (count > 0) {
|
||||
let at = 0;
|
||||
for (;;) {
|
||||
const left = 2 * at + 1;
|
||||
if (left >= count) {
|
||||
break;
|
||||
}
|
||||
const right = left + 1;
|
||||
const child = right < count && this.priorities[right] < this.priorities[left] ? right : left;
|
||||
if (this.priorities[child] >= lastPriority) {
|
||||
break;
|
||||
}
|
||||
this.ids[at] = this.ids[child];
|
||||
this.priorities[at] = this.priorities[child];
|
||||
at = child;
|
||||
}
|
||||
this.ids[at] = lastId;
|
||||
this.priorities[at] = lastPriority;
|
||||
}
|
||||
return top;
|
||||
}
|
||||
}
|
||||
|
||||
function rebuild(cameFrom: Map<number, number>, fromId: number, toId: number): number[] {
|
||||
const stars = [toId];
|
||||
let at = toId;
|
||||
while (at !== fromId) {
|
||||
const previous = cameFrom.get(at);
|
||||
if (previous === undefined) {
|
||||
return [];
|
||||
}
|
||||
stars.push(previous);
|
||||
at = previous;
|
||||
}
|
||||
return stars.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* The shortest chain from one star to another in which no single hop exceeds `rangePc`, or no
|
||||
* chain where the catalogue holds none within the search's budget.
|
||||
*
|
||||
* Shortest by total distance travelled rather than by number of hops: two chains of the same
|
||||
* length are not equally good, and the one that covers less ground is the one a reader means by
|
||||
* "the way there". Neighbours are asked for as the search reaches each star rather than built
|
||||
* into a graph first, so finding one route never costs a pass over the whole catalogue.
|
||||
*
|
||||
* An A* search: each star waits its turn by the distance travelled to it plus the straight line
|
||||
* on to the destination, which no chain can beat, so the search heads for the destination rather
|
||||
* than widening evenly in every direction. Widening evenly is what the Gaia catalogue broke. From
|
||||
* the Sun it spent its whole budget on the 20 000 stars nearest, all inside about 40 pc, and so
|
||||
* found no route to anything farther at any range; Mirfak, 155 pc out, is 27 jumps at 8 pc.
|
||||
*
|
||||
* "No route" and "no chain" are not the same answer: a search that spends {@link MAX_VISITED}
|
||||
* reports that it gave up, so nothing downstream reads it as proof that no chain exists.
|
||||
*/
|
||||
export function routeBetween(index: StarNeighbourhood, fromId: number, toId: number, rangePc: number): RouteSearch {
|
||||
const origin = index.point(fromId);
|
||||
const destination = index.point(toId);
|
||||
if (fromId === toId || rangePc <= 0 || !origin || !destination) {
|
||||
return { route: null, gaveUp: false };
|
||||
}
|
||||
const straightLineOn = (x: number, y: number, z: number) => Math.hypot(destination.x - x, destination.y - y, destination.z - z);
|
||||
|
||||
const travelled = new Map<number, number>([[fromId, 0]]);
|
||||
const cameFrom = new Map<number, number>();
|
||||
// Each hop's length as the range test measured it. The route's longest hop is read from these
|
||||
// rather than measured again, so a range set to it is sure to admit the route a second time,
|
||||
// which is what `minimumRangeBetween` relies on.
|
||||
const hopTo = new Map<number, number>();
|
||||
const settled = new Set<number>();
|
||||
const frontier = new Frontier();
|
||||
frontier.push(fromId, straightLineOn(origin.x, origin.y, origin.z));
|
||||
|
||||
let gaveUp = false;
|
||||
while (frontier.size > 0) {
|
||||
const starId = frontier.pop();
|
||||
if (settled.has(starId)) {
|
||||
continue;
|
||||
}
|
||||
// Counted against the budget only once the frontier has been drained of stale duplicates, so
|
||||
// the flag below records why the search stopped rather than how full the set happened to be.
|
||||
if (settled.size >= MAX_VISITED) {
|
||||
gaveUp = true;
|
||||
break;
|
||||
}
|
||||
settled.add(starId);
|
||||
const costHere = travelled.get(starId)!;
|
||||
|
||||
if (starId === toId) {
|
||||
const stars = rebuild(cameFrom, fromId, toId);
|
||||
if (stars.length === 0) {
|
||||
return { route: null, gaveUp: false };
|
||||
}
|
||||
let longestHopPc = 0;
|
||||
for (let i = 1; i < stars.length; i++) {
|
||||
longestHopPc = Math.max(longestHopPc, hopTo.get(stars[i])!);
|
||||
}
|
||||
return { route: { stars, totalPc: costHere, longestHopPc }, gaveUp: false };
|
||||
}
|
||||
|
||||
index.forEachWithin(starId, rangePc, (neighbour, distancePc) => {
|
||||
if (settled.has(neighbour.id)) {
|
||||
return;
|
||||
}
|
||||
const cost = costHere + distancePc;
|
||||
if (cost < (travelled.get(neighbour.id) ?? Number.POSITIVE_INFINITY)) {
|
||||
travelled.set(neighbour.id, cost);
|
||||
cameFrom.set(neighbour.id, starId);
|
||||
hopTo.set(neighbour.id, distancePc);
|
||||
frontier.push(neighbour.id, cost + straightLineOn(neighbour.x, neighbour.y, neighbour.z));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// An empty frontier means the range reaches nothing further; a spent budget means only that the
|
||||
// search stopped looking.
|
||||
return { route: null, gaveUp };
|
||||
}
|
||||
|
||||
/**
|
||||
* A range at which a chain exists between two stars — the shortest, to within
|
||||
* `RANGE_RESOLUTION_PC`, where every shorter range could be ruled out — or `null` where no chain
|
||||
* was found up to `ceilingPc`.
|
||||
*
|
||||
* This is what turns "no route" from a dead end into an answer: the range control can be told what
|
||||
* it would have to be raised to. The figure aimed at is the minimax path, the chain whose longest
|
||||
* hop is as short as possible. It used to be searched for directly, widening from the departure in
|
||||
* order of the worst hop needed, which from the Sun meant exhausting the whole dense core before
|
||||
* anything farther could be reached: it gave up with nothing after up to a minute. Whether a chain
|
||||
* exists can only become truer as the range grows, so the range is bisected instead, each step one
|
||||
* directed `routeBetween`.
|
||||
*
|
||||
* Each step has to answer "is there a chain at this range", and a search that gives up answers
|
||||
* nothing. It is still worth carrying on from — the ranges above it are the ones left to try — but
|
||||
* the result is no longer the least range, only a range that works, and `least` says which. The
|
||||
* number of steps that may give up is bounded for the same reason: each one walks the whole budget,
|
||||
* and 11 s of them for a star at 236 pc bought two decimal places nobody reads. Bounded more
|
||||
* loosely before the bisection has found a range of its own, since until then the only range it
|
||||
* could offer is the ceiling's, which is the control's maximum, for crossings that work well below
|
||||
* it. See {@link MAX_RANGE_GIVE_UPS}.
|
||||
*/
|
||||
export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, toId: number, ceilingPc: number): RangeSearch {
|
||||
const widest = routeBetween(index, fromId, toId, ceilingPc);
|
||||
if (!widest.route) {
|
||||
return { rangePc: null, least: !widest.gaveUp };
|
||||
}
|
||||
const ceilingHopPc = widest.route.longestHopPc;
|
||||
let unreachable = 0;
|
||||
let reachable = ceilingHopPc;
|
||||
let giveUps = 0;
|
||||
// While `reachable` is still the ceiling route's own longest hop the bisection has nothing of its
|
||||
// own to answer with, and that figure sends the control to its maximum for a crossing that works
|
||||
// well below — 8.00 pc for a star that routes at 6. So it is allowed more probes there, not
|
||||
// unlimited ones: the panel is waiting on this.
|
||||
while (reachable - unreachable > RANGE_RESOLUTION_PC && giveUps < (reachable === ceilingHopPc ? MAX_UNEARNED_GIVE_UPS : MAX_RANGE_GIVE_UPS)) {
|
||||
const range = (unreachable + reachable) / 2;
|
||||
const { route, gaveUp } = routeBetween(index, fromId, toId, range);
|
||||
if (route) {
|
||||
reachable = route.longestHopPc;
|
||||
} else {
|
||||
// A search that gave up is worth going on from — the ranges above it are the ones left to
|
||||
// try — but it is not evidence that nothing routes here, so the answer stops being the least.
|
||||
unreachable = range;
|
||||
giveUps += gaveUp ? 1 : 0;
|
||||
}
|
||||
}
|
||||
// Without a give-up the loop can only have ended by closing on the resolution, so that is the least.
|
||||
return { rangePc: reachable, least: giveUps === 0 };
|
||||
}
|
||||
|
||||
/** How much of a graph to keep: the links nearest a point, up to a total length. */
|
||||
export interface LinkBudget {
|
||||
/** Links are kept in order of how near their nearer end is to this point. */
|
||||
readonly centre: { readonly x: number; readonly y: number; readonly z: number };
|
||||
/** The most the kept links may add up to, end to end, in parsecs. */
|
||||
readonly lengthPc: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many distance bands a budgeted graph is split into to find where its budget runs out, so that
|
||||
* only the links in that one band are sorted rather than all of them.
|
||||
*/
|
||||
const DISTANCE_BANDS = 4096;
|
||||
|
||||
/**
|
||||
* Every link within `rangePc` between two of the stars `index` holds, each pair once, as vertex
|
||||
* pairs ready to draw: six floats a link, one end then the other. With a `budget`, only the links
|
||||
* nearest its centre, as many as fit its length.
|
||||
*
|
||||
* For drawing the graph, which is the only thing that wants all of it: routing asks for a star's
|
||||
* neighbours as it reaches that star and never builds this. Written straight into floats rather
|
||||
* than collected as link objects first, since at 8 pc the drawn stars alone have hundreds of
|
||||
* thousands of links, and the whole catalogue 3.7 million.
|
||||
*/
|
||||
export function jumpLinkSegments(index: StarNeighbourhood, rangePc: number, budget?: LinkBudget): Float32Array {
|
||||
let vertices = new Float32Array(6 * 4096);
|
||||
let length = 0;
|
||||
index.forEachPairWithin(rangePc, (a, b) => {
|
||||
if (length + 6 > vertices.length) {
|
||||
const grown = new Float32Array(vertices.length * 2);
|
||||
grown.set(vertices);
|
||||
vertices = grown;
|
||||
}
|
||||
vertices[length++] = a.x;
|
||||
vertices[length++] = a.y;
|
||||
vertices[length++] = a.z;
|
||||
vertices[length++] = b.x;
|
||||
vertices[length++] = b.y;
|
||||
vertices[length++] = b.z;
|
||||
});
|
||||
if (!budget) {
|
||||
// Exact length rather than a view on the grown buffer: the answer is transferred whole, and a
|
||||
// view would carry up to as much again in unused capacity with it.
|
||||
return vertices.slice(0, length);
|
||||
}
|
||||
|
||||
// Each link's nearer end's distance from the centre, and its length.
|
||||
const { centre } = budget;
|
||||
const count = length / 6;
|
||||
const nearness = new Float32Array(count);
|
||||
const lengths = new Float32Array(count);
|
||||
let totalPc = 0;
|
||||
let farthest = 0;
|
||||
for (let link = 0; link < count; link++) {
|
||||
const at = link * 6;
|
||||
const ax = vertices[at] - centre.x;
|
||||
const ay = vertices[at + 1] - centre.y;
|
||||
const az = vertices[at + 2] - centre.z;
|
||||
const bx = vertices[at + 3] - centre.x;
|
||||
const by = vertices[at + 4] - centre.y;
|
||||
const bz = vertices[at + 5] - centre.z;
|
||||
nearness[link] = Math.sqrt(Math.min(ax * ax + ay * ay + az * az, bx * bx + by * by + bz * bz));
|
||||
lengths[link] = Math.hypot(bx - ax, by - ay, bz - az);
|
||||
totalPc += lengths[link];
|
||||
farthest = Math.max(farthest, nearness[link]);
|
||||
}
|
||||
if (totalPc <= budget.lengthPc) {
|
||||
return vertices.slice(0, length);
|
||||
}
|
||||
|
||||
// Nearest first, without sorting them all: every link in the bands before the one where the budget
|
||||
// runs out fits, and only that band's links are sorted to see how many of them do. Sorting all
|
||||
// 730 000 links at 30 pc from the Sun to keep 4 400 doubled the time a graph took in the worker.
|
||||
const bands = new Uint16Array(count);
|
||||
const bandLengths = new Float64Array(DISTANCE_BANDS);
|
||||
const bandsPerPc = farthest > 0 ? DISTANCE_BANDS / farthest : 0;
|
||||
for (let link = 0; link < count; link++) {
|
||||
bands[link] = Math.min(DISTANCE_BANDS - 1, Math.floor(nearness[link] * bandsPerPc));
|
||||
bandLengths[bands[link]] += lengths[link];
|
||||
}
|
||||
let lastBand = 0;
|
||||
let keptPc = 0;
|
||||
while (keptPc + bandLengths[lastBand] <= budget.lengthPc) {
|
||||
keptPc += bandLengths[lastBand++];
|
||||
}
|
||||
const keptLinks: number[] = [];
|
||||
const boundary: number[] = [];
|
||||
for (let link = 0; link < count; link++) {
|
||||
const band = bands[link];
|
||||
if (band < lastBand) {
|
||||
keptLinks.push(link);
|
||||
} else if (band === lastBand) {
|
||||
boundary.push(link);
|
||||
}
|
||||
}
|
||||
boundary.sort((a, b) => nearness[a] - nearness[b] || a - b);
|
||||
for (const link of boundary) {
|
||||
if (keptPc + lengths[link] > budget.lengthPc) {
|
||||
break;
|
||||
}
|
||||
keptPc += lengths[link];
|
||||
keptLinks.push(link);
|
||||
}
|
||||
|
||||
const kept = new Float32Array(keptLinks.length * 6);
|
||||
keptLinks.forEach((link, at) => kept.set(vertices.subarray(link * 6, link * 6 + 6), at * 6));
|
||||
return kept;
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import { GM_SUN_AU3_PER_DAY2, DEFAULT_EPOCH_JD } from './constants';
|
||||
import {
|
||||
gravitationalParameterFromPeriod,
|
||||
isPropagatableOrbit,
|
||||
meanElementsAt,
|
||||
meanMotionRadPerDay,
|
||||
orbitEllipsePoints,
|
||||
orbitalPeriodDays,
|
||||
positionAtEpoch,
|
||||
positionAtTrueAnomaly,
|
||||
propagateOrbit,
|
||||
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', () => {
|
||||
it('samples a closed loop whose distances stay within the periapsis/apoapsis bounds', () => {
|
||||
const elements = resolveOrbitalElements({ semiMajorAxisAu: 5, eccentricity: 0.4 });
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { CartesianCoordinates } from './coordinates';
|
||||
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 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,
|
||||
@@ -208,17 +209,63 @@ export function positionAtTrueAnomaly(elements: OrbitalElements, trueAnomalyRad:
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagates `elements` to Julian date `epochJdEval`, returning the body's position (AU)
|
||||
* relative to its central body. This is the app's "current epoch" evaluation used for live
|
||||
* (and future time-scrubbable) positions, as opposed to {@link orbitEllipsePoints} which
|
||||
* The rates of an orbit that only goes round: Kepler's mean motion from the central mass, with
|
||||
* nothing turning. What an exoplanet has, since the archive publishes no precession.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
export function propagateOrbit(elements: OrbitalElements, gmAu3PerDay2: number, epochJdEval: number): CartesianCoordinates {
|
||||
const meanMotion = meanMotionRadPerDay(elements.semiMajorAxisAu, gmAu3PerDay2);
|
||||
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);
|
||||
return positionAtEpoch(meanElementsAt(elements, keplerRates(elements.semiMajorAxisAu, gmAu3PerDay2), epochJdEval));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)!);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expect(equilibriumTemperatureK(null, 1)).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));
|
||||
}
|
||||
|
||||
/** 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
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* 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(
|
||||
luminositySolar: number | null | undefined,
|
||||
@@ -106,7 +113,8 @@ export function equilibriumTemperatureK(
|
||||
if (!luminositySolar || !semiMajorAxisAu || luminositySolar <= 0 || semiMajorAxisAu <= 0) {
|
||||
return null;
|
||||
}
|
||||
return SOLAR_EQUILIBRIUM_TEMPERATURE_K * Math.pow(luminositySolar, 0.25) * Math.pow(semiMajorAxisAu, -0.5) * Math.pow(1 - bondAlbedo, 0.25);
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { jumpLinkSegments, minimumRangeBetween, routeBetween } from './jump-links';
|
||||
import { answerRouting, indexCatalogue } from './routing';
|
||||
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
|
||||
|
||||
/** Stars a parsec apart along x, then a gap of 5 pc to one more. */
|
||||
const POINTS = [...Array.from({ length: 5 }, (_, i) => ({ id: 10 + i, x: i, y: 0, z: 0 })), { id: 99, x: 9, y: 0, z: 0 }];
|
||||
|
||||
function catalogue() {
|
||||
return {
|
||||
kind: 'catalogue' as const,
|
||||
ids: Int32Array.from(POINTS, (point) => point.id),
|
||||
positions: Float32Array.from(POINTS.flatMap((point) => [point.x, point.y, point.z]))
|
||||
};
|
||||
}
|
||||
|
||||
describe('indexCatalogue', () => {
|
||||
it('indexes the catalogue as it was packed, id by id', () => {
|
||||
const index = indexCatalogue(catalogue());
|
||||
|
||||
for (const point of POINTS) {
|
||||
expect(index.point(point.id)).toEqual(point);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** The same index, counting the neighbour queries a search makes through it. */
|
||||
class CountingNeighbourhood extends StarNeighbourhood {
|
||||
queries = 0;
|
||||
|
||||
override forEachWithin(id: number, radiusPc: number, visit: (neighbour: StarPoint, distancePc: number) => void): void {
|
||||
this.queries++;
|
||||
super.forEachWithin(id, radiusPc, visit);
|
||||
}
|
||||
}
|
||||
|
||||
describe('answerRouting', () => {
|
||||
const index = indexCatalogue(catalogue());
|
||||
const direct = new StarNeighbourhood(POINTS);
|
||||
|
||||
it('answers a route the range allows, with nothing to raise it to', () => {
|
||||
const answer = answerRouting(index, { kind: 'route', requestId: 7, fromId: 10, toId: 14, rangePc: 1.5, ceilingPc: 8 });
|
||||
|
||||
expect(answer).toEqual({ kind: 'route', requestId: 7, route: routeBetween(direct, 10, 14, 1.5).route, neededRangePc: null, gaveUp: false, least: true });
|
||||
});
|
||||
|
||||
it('answers a route the range does not allow with the range that would', () => {
|
||||
const answer = answerRouting(index, { kind: 'route', requestId: 8, fromId: 10, toId: 99, rangePc: 1.5, ceilingPc: 8 });
|
||||
|
||||
expect(answer).toEqual({ kind: 'route', requestId: 8, route: null, neededRangePc: minimumRangeBetween(direct, 10, 99, 8).rangePc, gaveUp: false, least: true });
|
||||
expect(answer.kind === 'route' && answer.neededRangePc).toBeCloseTo(5, 1);
|
||||
});
|
||||
|
||||
it('passes on that the search gave up, rather than reporting no route', () => {
|
||||
// A crowd larger than a search's budget around the departure, and a destination nothing reaches:
|
||||
// the answer is "it gave up", and the scene has to be able to tell that from "there is none".
|
||||
let seed = 5;
|
||||
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 10 - 5;
|
||||
const crowd: StarPoint[] = Array.from({ length: 45000 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
|
||||
const knot = new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...crowd, { id: 99, x: 500, y: 0, z: 0 }], 0.5);
|
||||
|
||||
const answer = answerRouting(knot, { kind: 'route', requestId: 12, fromId: 0, toId: 99, rangePc: 0.5, ceilingPc: 0.5 });
|
||||
|
||||
expect(answer).toMatchObject({ route: null, neededRangePc: null, gaveUp: true });
|
||||
});
|
||||
|
||||
it('asks the ceiling its question once, rather than searching it again to answer it', () => {
|
||||
// At the panel's widest range the refused route and the range search are the same question, run
|
||||
// with the same arguments over the same index: the second pays the whole budget for the answer
|
||||
// the first already gave.
|
||||
const counting = new CountingNeighbourhood(POINTS);
|
||||
const oneSearch = new CountingNeighbourhood(POINTS);
|
||||
routeBetween(oneSearch, 10, 99, 3);
|
||||
|
||||
const answer = answerRouting(counting, { kind: 'route', requestId: 11, fromId: 10, toId: 99, rangePc: 3, ceilingPc: 3 });
|
||||
|
||||
expect(answer).toMatchObject({ route: null, neededRangePc: null });
|
||||
expect(counting.queries).toBe(oneSearch.queries);
|
||||
});
|
||||
|
||||
it('offers nothing to raise to when even the ceiling does not reach', () => {
|
||||
const answer = answerRouting(index, { kind: 'route', requestId: 9, fromId: 10, toId: 99, rangePc: 1.5, ceilingPc: 3 });
|
||||
|
||||
expect(answer).toMatchObject({ route: null, neededRangePc: null });
|
||||
});
|
||||
|
||||
it('answers the graph as the segments it draws', () => {
|
||||
const answer = answerRouting(index, { kind: 'links', requestId: 3, rangePc: 1.5, drawn: Uint32Array.from(POINTS.keys()) });
|
||||
|
||||
expect(answer.kind).toBe('links');
|
||||
expect(answer.requestId).toBe(3);
|
||||
expect(answer.kind === 'links' && linkEnds(answer.segments)).toEqual(linkEnds(jumpLinkSegments(direct, 1.5)));
|
||||
});
|
||||
|
||||
it('links only the drawn stars, including a pair exactly the range apart', () => {
|
||||
// Stars at x = 0, 1, 2 and 4 drawn; the one at 3, which would bridge 2 and 4, is not. At 1 pc
|
||||
// every link is exactly the range long, and the cells are exactly the range wide.
|
||||
const answer = answerRouting(index, { kind: 'links', requestId: 4, rangePc: 1, drawn: Uint32Array.of(0, 1, 2, 4) });
|
||||
|
||||
expect(answer.kind === 'links' && linkEnds(answer.segments)).toEqual(['0-1', '1-2']);
|
||||
});
|
||||
});
|
||||
|
||||
/** Each link as its two ends' x, lower first, in order: the pairs, whatever order they were walked in. */
|
||||
function linkEnds(segments: Float32Array): string[] {
|
||||
const ends: string[] = [];
|
||||
for (let at = 0; at < segments.length; at += 6) {
|
||||
ends.push([segments[at], segments[at + 3]].sort((a, b) => a - b).join('-'));
|
||||
}
|
||||
return ends.sort();
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* The route questions the map asks of the whole catalogue, as messages: what a worker is sent,
|
||||
* what it sends back, and the one function that turns the first into the second.
|
||||
*
|
||||
* Kept apart from the worker itself so it runs the same on either side of the thread boundary.
|
||||
* The scene asks through `RoutingClient`, which hands these to a Web Worker where one exists and
|
||||
* answers them in place where one does not.
|
||||
*/
|
||||
|
||||
import { jumpLinkSegments, LinkBudget, minimumRangeBetween, Route, routeBetween } from './jump-links';
|
||||
import { StarNeighbourhood } from './star-neighbourhood';
|
||||
|
||||
/** The catalogue, sent once: ids, and positions packed three to a star in the same order. */
|
||||
export interface RoutingCatalogue {
|
||||
readonly kind: 'catalogue';
|
||||
readonly ids: Int32Array;
|
||||
readonly positions: Float32Array;
|
||||
}
|
||||
|
||||
export type RoutingRequest =
|
||||
| { readonly kind: 'route'; readonly requestId: number; readonly fromId: number; readonly toId: number; readonly rangePc: number; readonly ceilingPc: number }
|
||||
/**
|
||||
* `drawn` is the stars the map is drawing, as positions in the catalogue that was sent: only they
|
||||
* are linked. `budget`, where given, keeps only the links nearest the view that fit its length.
|
||||
*/
|
||||
| { readonly kind: 'links'; readonly requestId: number; readonly rangePc: number; readonly drawn: Uint32Array; readonly budget?: LinkBudget };
|
||||
|
||||
export type RoutingResponse =
|
||||
/**
|
||||
* Two searches, and two things they can fail to prove, kept apart because they are printed as
|
||||
* different sentences. `gaveUp` is about the range that was asked for: true when that search
|
||||
* spent its budget rather than looking everywhere the range reaches. `least` is about the search
|
||||
* for a range that would work: true when it looked everywhere up to the ceiling, so `null` there
|
||||
* means no chain exists rather than none was found.
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'route';
|
||||
readonly requestId: number;
|
||||
readonly route: Route | null;
|
||||
readonly neededRangePc: number | null;
|
||||
readonly gaveUp: boolean;
|
||||
readonly least: boolean;
|
||||
}
|
||||
| { readonly kind: 'links'; readonly requestId: number; readonly segments: Float32Array }
|
||||
/** The question threw in the worker. Sent back so the request settles instead of waiting for good. */
|
||||
| { readonly kind: 'failed'; readonly requestId: number; readonly message: string };
|
||||
|
||||
/** A spatial index over a catalogue sent as a {@link RoutingCatalogue}. */
|
||||
export function indexCatalogue({ ids, positions }: RoutingCatalogue): StarNeighbourhood {
|
||||
return new StarNeighbourhood(Array.from(ids, (id, i) => ({ id, x: positions[i * 3], y: positions[i * 3 + 1], z: positions[i * 3 + 2] })));
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers one request. A route that cannot be made comes back with the range that would make one,
|
||||
* searched no wider than `ceilingPc`, so a refusal is usually also an offer — unless the searches
|
||||
* gave up, which is reported rather than passed off as "there is no route".
|
||||
*/
|
||||
export function answerRouting(index: StarNeighbourhood, request: RoutingRequest): RoutingResponse {
|
||||
if (request.kind === 'links') {
|
||||
// An index of its own over the drawn stars, in cells as wide as the range, so each cell is
|
||||
// paired with its immediate neighbours only: 14 cells a cell at 8 pc rather than 63.
|
||||
const drawn = new StarNeighbourhood(Array.from(request.drawn, (at) => index.pointAt(at)), request.rangePc);
|
||||
return { kind: 'links', requestId: request.requestId, segments: jumpLinkSegments(drawn, request.rangePc, request.budget) };
|
||||
}
|
||||
const { route, gaveUp } = routeBetween(index, request.fromId, request.toId, request.rangePc);
|
||||
// At the ceiling the question has just been asked: the range search would repeat it, identically
|
||||
// and at the same cost, before bisecting below it.
|
||||
if (route || request.rangePc >= request.ceilingPc) {
|
||||
// Asked at the ceiling, the one search answers both questions.
|
||||
return { kind: 'route', requestId: request.requestId, route, neededRangePc: null, gaveUp: !route && gaveUp, least: !gaveUp };
|
||||
}
|
||||
const needed = minimumRangeBetween(index, request.fromId, request.toId, request.ceilingPc);
|
||||
return { kind: 'route', requestId: request.requestId, route: null, neededRangePc: needed.rangePc, gaveUp, least: needed.least };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { answerRouting, indexCatalogue, RoutingCatalogue, RoutingRequest } from './routing';
|
||||
import { StarNeighbourhood } from './star-neighbourhood';
|
||||
|
||||
/**
|
||||
* Walks routes and builds the jump-link graph off the main thread. A search to a star 236 pc
|
||||
* away, and the range it would need when there is none, can take seconds; a graph of the drawn
|
||||
* stars at 8 pc is hundreds of thousands of links. On the page's own thread either stops the map
|
||||
* for as long as it runs.
|
||||
*/
|
||||
let index: StarNeighbourhood | undefined;
|
||||
|
||||
addEventListener('message', ({ data }: MessageEvent<RoutingCatalogue | RoutingRequest>) => {
|
||||
if (data.kind === 'catalogue') {
|
||||
index = indexCatalogue(data);
|
||||
return;
|
||||
}
|
||||
// The catalogue is always the first message, and a worker's messages arrive in order.
|
||||
try {
|
||||
const response = answerRouting(index!, data);
|
||||
postMessage(response, response.kind === 'links' ? [response.segments.buffer] : []);
|
||||
} catch (error) {
|
||||
postMessage({ kind: 'failed', requestId: data.requestId, message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
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', () => {
|
||||
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', () => {
|
||||
it('places the Sun near its real B-V of 0.65', () => {
|
||||
expect(spectralTypeToColorIndex('G2V')).toBeCloseTo(0.626, 2);
|
||||
@@ -83,3 +97,119 @@ describe('spectralTypeToColorIndex', () => {
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 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
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { StarRecord } from '../models/star.model';
|
||||
import { directionCosine, isSameStar, mergeStarCatalogues } 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. */
|
||||
function at(id: number, raDeg: number, decDeg: number, distancePc: number, overrides: Partial<StarRecord> = {}): StarRecord {
|
||||
@@ -13,24 +13,104 @@ function at(id: number, raDeg: number, decDeg: number, distancePc: number, overr
|
||||
const HIPPARCOS = { sourceId: 'hyg', parallaxPrecisionMas: 1 };
|
||||
const GAIA = { sourceId: 'gaia', parallaxPrecisionMas: 0.02 };
|
||||
|
||||
/** Degrees of right ascension that span `arcsec` on the sky at declination `decDeg`. */
|
||||
function arcsecOfRa(arcsec: number, decDeg: number): number {
|
||||
return arcsec / 3600 / Math.cos((decDeg * Math.PI) / 180);
|
||||
}
|
||||
|
||||
describe('isSameStar', () => {
|
||||
it('matches two catalogues reporting the same star', () => {
|
||||
expect(isSameStar(at(1, 101.28, -16.71, 2.64), at(2, 101.28, -16.71, 2.63))).toBe(true);
|
||||
});
|
||||
|
||||
it('matches within the angular tolerance and not beyond it', () => {
|
||||
const toleranceArcsec = MERGE_ANGULAR_TOLERANCE_DEG * 3600;
|
||||
expect(isSameStar(at(1, 200, 10, 100), at(2, 200 + arcsecOfRa(0.9 * toleranceArcsec, 10), 10, 100))).toBe(true);
|
||||
expect(isSameStar(at(1, 200, 10, 100), at(2, 200 + arcsecOfRa(1.1 * toleranceArcsec, 10), 10, 100))).toBe(false);
|
||||
});
|
||||
|
||||
it('tolerates the distance disagreement two parallaxes actually have', () => {
|
||||
// Hipparcos and Gaia routinely differ by tens of per cent at a few hundred parsecs. That
|
||||
// disagreement is the reason to prefer one of them, not evidence they are different stars.
|
||||
expect(isSameStar(at(1, 200, 10, 200), at(2, 200, 10, 260))).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a bright primary out of the entry of its faint companion', () => {
|
||||
// Gaia has no Sirius — it saturates — but has Sirius B, 6″ away at the same distance and ten
|
||||
// magnitudes fainter. Direction and distance say "same star"; the brightness says otherwise.
|
||||
const siriusB = at(1, 101.2875, -16.7161, 2.67, { name: 'Gaia DR3 2947050466531873024', magnitude: 8.5, source: 'gaia' });
|
||||
const sirius = at(32263, 101.2875 + arcsecOfRa(6.1, -16.7161), -16.7161, 2.637, { name: 'Sirius', magnitude: -1.44 });
|
||||
expect(isSameStar(siriusB, sirius)).toBe(false);
|
||||
expect(isSameStar(siriusB, { ...sirius, magnitude: 8.6 })).toBe(true);
|
||||
});
|
||||
|
||||
it('lets the folded entry be fainter, as a red star is in V, but not much brighter', () => {
|
||||
// Wolf 359 is V 13.45 in HYG and G 11.0 in Gaia — the same star, 5″ apart on a Gliese
|
||||
// position. Almach is V 2.1 and sits 10″ from γ² And, G 4.9: Gaia has no Almach, and its
|
||||
// name must not land on the companion.
|
||||
const wolf359 = at(1, 164.1, 7.0, 2.41, { name: 'Gaia DR3 3864972938605115520', magnitude: 11.0, source: 'gaia' });
|
||||
expect(isSameStar(wolf359, at(118720, 164.1 + arcsecOfRa(5, 7), 7.0, 2.39, { name: 'Wolf 359', magnitude: 13.45 }))).toBe(true);
|
||||
const gamma2And = at(2, 30.97, 42.33, 50, { name: 'Gaia DR3 346231302441905920', magnitude: 4.9, source: 'gaia' });
|
||||
expect(isSameStar(gamma2And, at(9640, 30.97 + arcsecOfRa(9.9, 42.33), 42.33, 50, { name: 'Almach', magnitude: 2.1 }))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not match two different stars that happen to be at the same distance', () => {
|
||||
expect(isSameStar(at(1, 200, 10, 200), at(2, 200.5, 10, 200))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not match along a line of sight when the distances genuinely conflict', () => {
|
||||
// Same direction, one three times further away: a background star, not the same object.
|
||||
expect(isSameStar(at(1, 200, 10, 100), at(2, 200, 10, 300))).toBe(false);
|
||||
it('takes two entries within three arcseconds for one star, whatever their distances say', () => {
|
||||
// HD 225021: 143.7 pc by its Hipparcos parallax, 239.4 by Gaia's, 0.01″ apart; HIP 82724:
|
||||
// 3.7 pc by Hipparcos, 62.8 by Gaia, 2.3″ apart. A coincidence of direction that close is
|
||||
// never chance at this depth; the parallax is what is wrong.
|
||||
const gaia = at(1, 1.72, -8.9, 239.4, { name: 'Gaia DR3 395581679270412160', source: 'gaia' });
|
||||
expect(isSameStar(gaia, at(213, 1.72 + arcsecOfRa(0.1, -8.9), -8.9, 143.7, { name: 'HD 225021' }))).toBe(true);
|
||||
expect(isSameStar(at(2, 253.6, -38.1, 62.8, { source: 'gaia' }), at(82724, 253.6 + arcsecOfRa(2.3, -38.1), -38.1, 3.7))).toBe(true);
|
||||
});
|
||||
|
||||
it('past those three arcseconds, does not match along a line of sight when the distances conflict', () => {
|
||||
// Nearly the same direction, one three times further away: a background star, not the same object.
|
||||
expect(isSameStar(at(1, 200, 10, 100), at(2, 200 + arcsecOfRa(5, 10), 10, 300))).toBe(false);
|
||||
});
|
||||
|
||||
it('still hears the brightness inside those three arcseconds', () => {
|
||||
// Ashlesha (ε Hya, V 3.38) has a companion 2.7″ away that Gaia does carry, three magnitudes
|
||||
// fainter, while it does not carry Ashlesha. Direction alone would put the name on the companion.
|
||||
const companion = at(1, 131.69, 6.42, 40, { name: 'Gaia DR3 1', magnitude: 6.7, source: 'gaia' });
|
||||
expect(isSameStar(companion, at(43109, 131.69 + arcsecOfRa(2.7, 6.42), 6.42, 40, { name: 'Ashlesha', magnitude: 3.38 }))).toBe(false);
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
@@ -72,6 +152,90 @@ describe('mergeStarCatalogues', () => {
|
||||
expect(summary.duplicates).toBe(1);
|
||||
});
|
||||
|
||||
it('gives a matched star the better position and the name somebody gave it', () => {
|
||||
// What a merge is for: Gaia knows where Proxima is to a fraction of a milliarcsecond and
|
||||
// calls it by a nineteen-digit number; HYG knows its name, its spectral type and its V
|
||||
// magnitude. Keeping one row whole loses half of that either way. The id follows the
|
||||
// description, so a star HYG knows keeps its HYG id from one refresh to the next.
|
||||
const hyg = at(70666, 217.4289, -62.6795, 1.2959, { name: 'Proxima Centauri', spectralType: 'M5Ve', magnitude: 11.01, colorIndex: 1.807 });
|
||||
const gaia = at(1000064182, 217.4289, -62.6795, 1.302, { name: 'Gaia DR3 5853498713190525696', spectralType: 'Unknown', magnitude: 8.985, colorIndex: 3.805, source: 'gaia' });
|
||||
const { stars, summary } = mergeStarCatalogues([{ ...HIPPARCOS, stars: [hyg] }, { ...GAIA, stars: [gaia] }]);
|
||||
|
||||
expect(stars).toEqual([{ ...hyg, x: gaia.x, y: gaia.y, z: gaia.z, source: 'gaia' }]);
|
||||
expect(summary.duplicates).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps 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', () => {
|
||||
// Gaia resolves doubles Hipparcos saw as one star: two source ids 0.8″ apart are two stars,
|
||||
// and only *another* catalogue can claim to have already listed either of them.
|
||||
const { stars, summary } = mergeStarCatalogues([{ ...GAIA, stars: [at(1, 10, 10, 100), at(2, 10 + arcsecOfRa(0.8, 10), 10, 100)] }]);
|
||||
expect(stars).toHaveLength(2);
|
||||
expect(summary.duplicates).toBe(0);
|
||||
});
|
||||
|
||||
it('folds an entry into the nearest match, and into each match once', () => {
|
||||
// Gliese lists both components of a double; Gaia resolves them 0.8″ apart. Each HYG
|
||||
// component must land on its own Gaia counterpart — not both on whichever the grid yields
|
||||
// first, and not both on the same one.
|
||||
const gaiaA = at(1, 10, 10, 2.68, { name: 'Gaia DR3 1', source: 'gaia' });
|
||||
const gaiaB = at(2, 10 + arcsecOfRa(0.8, 10), 10, 2.68, { name: 'Gaia DR3 2', source: 'gaia' });
|
||||
const a = at(118079, 10, 10, 2.63, { name: 'Gl 65A' });
|
||||
const b = at(118080, 10 + arcsecOfRa(0.8, 10), 10, 2.63, { name: 'Gl 65B' });
|
||||
const position = (star: StarRecord) => [star.x, star.y, star.z];
|
||||
|
||||
const nearest = mergeStarCatalogues([{ ...HIPPARCOS, stars: [b, a] }, { ...GAIA, stars: [gaiaA, gaiaB] }]);
|
||||
expect(nearest.stars.map((star) => [star.name, ...position(star)])).toEqual([['Gl 65A', ...position(gaiaA)], ['Gl 65B', ...position(gaiaB)]]);
|
||||
|
||||
const onePlace = mergeStarCatalogues([{ ...HIPPARCOS, stars: [a, { ...b, x: a.x, y: a.y, z: a.z }] }, { ...GAIA, stars: [gaiaA, gaiaB] }]);
|
||||
expect(onePlace.stars.map((star) => [star.name, ...position(star)])).toEqual([['Gl 65A', ...position(gaiaA)], ['Gl 65B', ...position(gaiaB)]]);
|
||||
});
|
||||
|
||||
it('keeps a star the better catalogue does not reach', () => {
|
||||
// The point of merging rather than replacing: Gaia is more precise but not a superset of
|
||||
// everything, and a bright star it omits should not vanish from the map.
|
||||
@@ -123,6 +287,13 @@ describe('mergeStarCatalogues', () => {
|
||||
]);
|
||||
expect(stars).toHaveLength(1);
|
||||
}
|
||||
|
||||
// And the one edge the grid has to wrap: 3.6″ apart, either side of 0h.
|
||||
const { stars } = mergeStarCatalogues([
|
||||
{ ...HIPPARCOS, stars: [at(1, 359.9995, 0, 100)] },
|
||||
{ ...GAIA, stars: [at(2, 0.0005, 0, 100)] }
|
||||
]);
|
||||
expect(stars).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles a single catalogue as a plain pass-through', () => {
|
||||
@@ -146,3 +317,137 @@ describe('mergeStarCatalogues', () => {
|
||||
expect(Date.now() - started).toBeLessThan(10000);
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it("draws a star both surveys measured at Gaia's distance", () => {
|
||||
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
|
||||
// gives, rather than at one a third short or dropped for having been misplaced.
|
||||
it('keeps a star Hipparcos put inside the cutoff, where Gaia puts it, even past the cutoff', () => {
|
||||
expect(placementDistancePc(200, 306, 8, 250)).toBe(306);
|
||||
});
|
||||
|
||||
// The mirror image: Hipparcos outside, Gaia inside. The Gaia download already holds the star,
|
||||
// and keeping the HYG row is what lets the merge give that entry its name.
|
||||
it('keeps a star only Gaia puts inside the cutoff', () => {
|
||||
expect(placementDistancePc(262, 241, 8, 250)).toBe(241);
|
||||
});
|
||||
|
||||
it('keeps a star Gaia measured and Hipparcos gave no distance for', () => {
|
||||
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', () => {
|
||||
expect(placementDistancePc(90, undefined, 8, 250)).toBe(90);
|
||||
});
|
||||
|
||||
it('drops a star both surveys put outside, or neither measured', () => {
|
||||
expect(placementDistancePc(300, 410, 8, 250)).toBeNull();
|
||||
expect(placementDistancePc(300, undefined, 8, 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isDesignation } from '../models/star-catalog';
|
||||
import { StarRecord } from '../models/star.model';
|
||||
|
||||
/**
|
||||
@@ -17,8 +18,66 @@ import { StarRecord } from '../models/star.model';
|
||||
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
|
||||
/** Angular separation, in degrees, below which two entries are taken to be the same star. */
|
||||
export const MERGE_ANGULAR_TOLERANCE_DEG = 1 / 3600;
|
||||
/**
|
||||
* Angular separation, in degrees, below which two entries are taken to be the same star.
|
||||
*
|
||||
* Every source arrives here at epoch J2000.0 — HYG publishes it, Gaia is carried back to it with
|
||||
* its own proper motions in `gaia.ts` — so what separates two entries of one star is measurement,
|
||||
* not motion. Left at their own epochs, sixteen years of proper motion put Proxima's two entries
|
||||
* 62″ apart and Barnard's 166″, and an arcsecond of tolerance kept every fast star twice while
|
||||
* folding the slow ones.
|
||||
*
|
||||
* What measurement leaves is under an arcsecond for a Hipparcos position — 55 457 of the 56 000
|
||||
* stars both catalogues hold — and up to tens of arcseconds for the Gliese-only entries HYG
|
||||
* carries without Hipparcos astrometry: Wolf 359 sits 5″ from where Gaia has it, Ross 248 12″.
|
||||
* Fifteen arcseconds takes those. The sky is sparse enough at this depth that shifting every
|
||||
* entry a quarter of a degree finds only 16 chance neighbours within it, against 116 real ones
|
||||
* between ten and fifteen; past twenty the two curves run together.
|
||||
*/
|
||||
export const MERGE_ANGULAR_TOLERANCE_DEG = 15 / 3600;
|
||||
|
||||
/**
|
||||
* Angular separation, in degrees, under which the distances are not consulted. A coincidence of
|
||||
* direction this close is never chance at this depth — the quarter-degree shift finds none under
|
||||
* 3″ — so two entries this close are one star whatever their parallaxes say, and what they say
|
||||
* is often a Hipparcos parallax off by half: 1 500 stars sat within this of their Gaia entry and
|
||||
* were kept twice by the distance test, thirty of them at a false few parsecs from the Sun
|
||||
* (HIP 82724 at 3.7 pc, where Gaia has it at 62.8). Brightness keeps its say at any separation,
|
||||
* because a companion can sit this close: Ashlesha's is 2.7″ away and three magnitudes fainter.
|
||||
*/
|
||||
export const MERGE_CERTAIN_ANGULAR_TOLERANCE_DEG = 3 / 3600;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* still be the same star. Bands differ, and not symmetrically: a red dwarf is three magnitudes
|
||||
* fainter in HYG's V than in Gaia's G, so the folded entry may be up to five fainter. A star is
|
||||
* never much brighter in V than in G, though, and an entry a magnitude brighter than what is
|
||||
* already at that spot is a primary Gaia does not carry — it saturates below G ≈ 3 — sitting
|
||||
* beside its companion: Sirius 6″ from Sirius B and ten magnitudes brighter, Almach 10″ from
|
||||
* γ² And, Alfirk 13″ from β Cep B. Without this the primary's name lands on the companion's
|
||||
* entry, and the companion is gone.
|
||||
*/
|
||||
export const MERGE_FAINTER_TOLERANCE = 5;
|
||||
export const MERGE_BRIGHTER_TOLERANCE = 1;
|
||||
|
||||
/**
|
||||
* How far two distances may disagree, as a ratio, and still describe the same star. Generous on
|
||||
@@ -27,6 +86,65 @@ export const MERGE_ANGULAR_TOLERANCE_DEG = 1 / 3600;
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* At whichever distance has the smaller relative error, given both; Gaia's without them, or
|
||||
* 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
|
||||
* downloaded from the other, and drawn at 200. That was 83% of the HYG stars left without a
|
||||
* Gaia counterpart, and at the median Hipparcos had them at two-thirds of Gaia's distance.
|
||||
*
|
||||
* Now a star either survey places inside `cutoffPc` is kept, and every kept star sits where the
|
||||
* better measurement puts it, inside the cutoff or not. So is every star the naked eye sees, at
|
||||
* 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,
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
const inside = magnitude <= NAKED_EYE_MAGNITUDE || best <= cutoffPc || (hipparcosPc !== undefined && hipparcosPc <= cutoffPc);
|
||||
return inside ? best : null;
|
||||
}
|
||||
|
||||
export interface MergeCandidate {
|
||||
readonly sourceId: string;
|
||||
/** Lower is better — the parallax precision this source measures with, in milliarcseconds. */
|
||||
@@ -36,7 +154,7 @@ export interface MergeCandidate {
|
||||
|
||||
export interface MergeSummary {
|
||||
readonly total: number;
|
||||
/** Entries dropped because a better-measured catalogue already had that star. */
|
||||
/** Entries folded into one a better-measured catalogue already had; see {@link combine}. */
|
||||
readonly duplicates: number;
|
||||
readonly bySource: Readonly<Record<string, number>>;
|
||||
}
|
||||
@@ -60,8 +178,13 @@ function distanceOf(star: StarRecord): number {
|
||||
*/
|
||||
const SKY_CELL_DEG = 0.5;
|
||||
|
||||
const RA_CELLS = 360 / SKY_CELL_DEG;
|
||||
|
||||
function cellKey(raDeg: number, decDeg: number): string {
|
||||
return `${Math.floor(raDeg / SKY_CELL_DEG)}:${Math.floor(decDeg / SKY_CELL_DEG)}`;
|
||||
// Right ascension wraps: the cell after 359.5° is 0°, so a pair straddling 0h shares a
|
||||
// neighbourhood rather than sitting 719 cells apart.
|
||||
const raCell = ((Math.floor(raDeg / SKY_CELL_DEG) % RA_CELLS) + RA_CELLS) % RA_CELLS;
|
||||
return `${raCell}:${Math.floor(decDeg / SKY_CELL_DEG)}`;
|
||||
}
|
||||
|
||||
function skyAngles(star: StarRecord): { raDeg: number; decDeg: number } {
|
||||
@@ -86,9 +209,22 @@ export function directionCosine(a: StarRecord, b: StarRecord): number {
|
||||
return Math.max(-1, Math.min(1, ax * bx + ay * by + az * bz));
|
||||
}
|
||||
|
||||
/** Whether two entries describe the same star: same direction, and distances not in conflict. */
|
||||
export function isSameStar(a: StarRecord, b: StarRecord): boolean {
|
||||
const [near, far] = [distanceOf(a), distanceOf(b)].sort((p, q) => p - q);
|
||||
/** 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
|
||||
* conflict and — unless the directions agree closely enough to settle it, or the two move
|
||||
* together — the distance not in conflict either.
|
||||
*/
|
||||
export function isSameStar(kept: StarRecord, entry: StarRecord): boolean {
|
||||
const [near, far] = [distanceOf(kept), distanceOf(entry)].sort((p, q) => p - q);
|
||||
|
||||
// The Sun sits at the origin of this coordinate system and so has no direction at all, which
|
||||
// the angular test below cannot speak about. Every catalogue contains it, so without this the
|
||||
@@ -97,25 +233,148 @@ export function isSameStar(a: StarRecord, b: StarRecord): boolean {
|
||||
return far === 0;
|
||||
}
|
||||
|
||||
const separationDeg = Math.acos(directionCosine(a, b)) / DEG_TO_RAD;
|
||||
if (separationDeg > MERGE_ANGULAR_TOLERANCE_DEG) {
|
||||
const separationDeg = Math.acos(directionCosine(kept, entry)) / DEG_TO_RAD;
|
||||
const comoving = movesWith(kept, entry);
|
||||
if (separationDeg > (comoving ? MERGE_COMOVING_ANGULAR_TOLERANCE_DEG : MERGE_ANGULAR_TOLERANCE_DEG)) {
|
||||
return false;
|
||||
}
|
||||
const fainterBy = entry.magnitude - kept.magnitude;
|
||||
if (fainterBy < -MERGE_BRIGHTER_TOLERANCE || fainterBy > MERGE_FAINTER_TOLERANCE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (comoving || separationDeg <= MERGE_CERTAIN_ANGULAR_TOLERANCE_DEG) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (far - near) / near <= MERGE_DISTANCE_RATIO_TOLERANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry from two of the same star: the position of the better-measured one — inserted first,
|
||||
* so it is the one already `kept` — and the description of whichever knows the star as more than
|
||||
* a catalogue number. HYG's "Proxima Centauri", "M5Ve" and V magnitude over Gaia's
|
||||
* "Gaia DR3 5853498713190525696", "Unknown" and G; keeping either row whole loses half of that,
|
||||
* and keeping Gaia's whole once cost the map 102 proper names and 32 000 spectral types. The id
|
||||
* travels with the description, so a star HYG knows keeps its HYG id from one refresh to the next,
|
||||
* 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, described = isDesignation(kept) && !isDesignation(other) ? other : kept): StarRecord {
|
||||
const otherBetter = other.distanceError !== undefined && kept.distanceError !== undefined && other.distanceError < kept.distanceError;
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unions the given catalogues, keeping one entry per star.
|
||||
*
|
||||
* Sources are taken in order of how precisely they measure parallax, best first, and a star is
|
||||
* only added if no better-measured catalogue already has it. So where Gaia and Hipparcos
|
||||
* overlap, the position is Gaia's; where only Hipparcos reaches, the star is still there.
|
||||
* Sources are taken in order of how precisely they measure parallax, best first. An entry that a
|
||||
* better-measured catalogue already has is folded into that entry — the nearest one within the
|
||||
* tolerance, see {@link combine} for what each side keeps. Only entries from *other* sources
|
||||
* count as already there: a catalogue does not list a star twice, so two of its own entries
|
||||
* within the tolerance are two stars, typically a double that Gaia resolves and Hipparcos did
|
||||
* not. Where only one source reaches, the star is still there.
|
||||
*/
|
||||
export function mergeStarCatalogues(candidates: readonly MergeCandidate[]): { stars: StarRecord[]; summary: MergeSummary } {
|
||||
const ordered = [...candidates].sort((a, b) => a.parallaxPrecisionMas - b.parallaxPrecisionMas);
|
||||
const merged: StarRecord[] = [];
|
||||
const grid = new Map<string, StarRecord[]>();
|
||||
const grid = new Map<string, number[]>();
|
||||
// Entries that already absorbed one from a source, as `${index}/${source}`: a double that
|
||||
// Gliese lists as two entries at one position has to land on two Gaia entries, not on one.
|
||||
const taken = new Set<string>();
|
||||
const bySource: Record<string, number> = {};
|
||||
let duplicates = 0;
|
||||
|
||||
@@ -123,24 +382,41 @@ export function mergeStarCatalogues(candidates: readonly MergeCandidate[]): { st
|
||||
bySource[candidate.sourceId] = 0;
|
||||
|
||||
for (const star of candidate.stars) {
|
||||
const { raDeg, decDeg } = skyAngles(star);
|
||||
const alreadyPresent = neighbouringCells(raDeg, decDeg).some((key) => (grid.get(key) ?? []).some((existing) => isSameStar(existing, star)));
|
||||
const entry: StarRecord = { ...star, source: star.source ?? candidate.sourceId };
|
||||
const { raDeg, decDeg } = skyAngles(entry);
|
||||
|
||||
if (alreadyPresent) {
|
||||
let match: number | null = null;
|
||||
let matchCosine = -1;
|
||||
for (const key of neighbouringCells(raDeg, decDeg)) {
|
||||
for (const index of grid.get(key) ?? []) {
|
||||
const existing = merged[index];
|
||||
if (existing.source === entry.source || taken.has(`${index}/${entry.source}`) || !isSameStar(existing, entry)) {
|
||||
continue;
|
||||
}
|
||||
const cosine = directionCosine(existing, entry);
|
||||
if (cosine > matchCosine) {
|
||||
match = index;
|
||||
matchCosine = cosine;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (match !== null) {
|
||||
merged[match] = combine(merged[match], entry);
|
||||
taken.add(`${match}/${entry.source}`);
|
||||
duplicates++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const withSource: StarRecord = { ...star, source: star.source ?? candidate.sourceId };
|
||||
merged.push(withSource);
|
||||
const index = merged.push(entry) - 1;
|
||||
bySource[candidate.sourceId]++;
|
||||
|
||||
const key = cellKey(raDeg, decDeg);
|
||||
const cell = grid.get(key);
|
||||
if (cell) {
|
||||
cell.push(withSource);
|
||||
cell.push(index);
|
||||
} else {
|
||||
grid.set(key, [withSource]);
|
||||
grid.set(key, [index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
|
||||
|
||||
/** A line of stars one parsec apart along x, so every expected distance is an integer. */
|
||||
function line(count: number): StarPoint[] {
|
||||
return Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 }));
|
||||
}
|
||||
|
||||
function ids(found: { id: number }[]): number[] {
|
||||
return found.map((neighbour) => neighbour.id);
|
||||
}
|
||||
|
||||
describe('StarNeighbourhood', () => {
|
||||
it('names the nearest stars in order, and never the star itself', () => {
|
||||
const index = new StarNeighbourhood(line(10));
|
||||
|
||||
expect(ids(index.nearest(4, 3))).toEqual([3, 5, 2]);
|
||||
});
|
||||
|
||||
it('measures the separation it found each star by', () => {
|
||||
const index = new StarNeighbourhood([
|
||||
{ id: 1, x: 0, y: 0, z: 0 },
|
||||
{ id: 2, x: 3, y: 4, z: 0 }
|
||||
]);
|
||||
|
||||
expect(index.nearest(1, 1)[0].distancePc).toBeCloseTo(5);
|
||||
});
|
||||
|
||||
it('reaches past its own cell for a star sitting alone in one', () => {
|
||||
// 5 pc cells: these three are in three different cells, and the nearest is 12 pc out.
|
||||
const index = new StarNeighbourhood([
|
||||
{ id: 1, x: 0, y: 0, z: 0 },
|
||||
{ id: 2, x: 12, y: 0, z: 0 },
|
||||
{ id: 3, x: 40, y: 0, z: 0 }
|
||||
]);
|
||||
|
||||
expect(ids(index.nearest(1, 2))).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it('does not stop at the first ring that fills the list, where the next holds something closer', () => {
|
||||
// The diagonal neighbour is in the ring-1 shell but 8.7 pc away; the one straight along x is
|
||||
// in the ring-2 shell and only 6 pc away. Stopping at the first full ring would miss it.
|
||||
const index = new StarNeighbourhood([
|
||||
{ id: 1, x: 0, y: 0, z: 0 },
|
||||
{ id: 2, x: 5, y: 5, z: 5 },
|
||||
{ id: 3, x: 6, y: 0, z: 0 }
|
||||
]);
|
||||
|
||||
expect(ids(index.nearest(1, 1))).toEqual([3]);
|
||||
});
|
||||
|
||||
it('agrees with a brute-force scan over a pseudo-random cloud', () => {
|
||||
// The property that matters: the grid is an optimisation, never a different answer.
|
||||
let seed = 7;
|
||||
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 100 - 50;
|
||||
const cloud: StarPoint[] = Array.from({ length: 400 }, (_, id) => ({ id, x: random(), y: random(), z: random() }));
|
||||
const index = new StarNeighbourhood(cloud);
|
||||
|
||||
for (const origin of [cloud[0], cloud[199], cloud[399]]) {
|
||||
const brute = cloud
|
||||
.filter((point) => point.id !== origin.id)
|
||||
.map((point) => ({ id: point.id, distancePc: Math.hypot(point.x - origin.x, point.y - origin.y, point.z - origin.z) }))
|
||||
.sort((a, b) => a.distancePc - b.distancePc);
|
||||
|
||||
expect(ids(index.nearest(origin.id, 5))).toEqual(ids(brute.slice(0, 5)));
|
||||
expect(ids(index.within(origin.id, 20))).toEqual(ids(brute.filter((neighbour) => neighbour.distancePc <= 20)));
|
||||
}
|
||||
});
|
||||
|
||||
it('takes only the stars a filter accepts', () => {
|
||||
const index = new StarNeighbourhood(line(10));
|
||||
|
||||
expect(ids(index.nearest(4, 2, (point) => point.id % 2 === 0))).toEqual([2, 6]);
|
||||
});
|
||||
|
||||
it('puts the stars it is told to prefer first, and fills with the rest only when short', () => {
|
||||
const index = new StarNeighbourhood(line(10));
|
||||
const even = (point: StarPoint) => point.id % 2 === 0;
|
||||
|
||||
// Enough even stars: the odd ones next door, though nearer, do not get a look in.
|
||||
expect(ids(index.nearestPreferring(4, 2, even))).toEqual([2, 6]);
|
||||
// Not enough: every even star in reach, then the nearest of the others.
|
||||
expect(ids(index.nearestPreferring(4, 6, even))).toEqual([2, 6, 0, 8, 3, 5]);
|
||||
});
|
||||
|
||||
it('answers nothing for a star it has never heard of', () => {
|
||||
const index = new StarNeighbourhood(line(3));
|
||||
|
||||
expect(index.nearest(99, 3)).toEqual([]);
|
||||
expect(index.within(99, 10)).toEqual([]);
|
||||
expect(index.point(99)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('asks for nothing and gets nothing', () => {
|
||||
const index = new StarNeighbourhood(line(5));
|
||||
|
||||
expect(index.nearest(0, 0)).toEqual([]);
|
||||
expect(index.within(0, 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds every star inside a radius and none on the far side of it', () => {
|
||||
const index = new StarNeighbourhood(line(20));
|
||||
|
||||
expect(ids(index.within(10, 2.5))).toEqual([9, 11, 8, 12]);
|
||||
});
|
||||
|
||||
it('holds stars that share a position without losing either', () => {
|
||||
// Real catalogue rows do this: Gl 65 A and B are one binary, two entries, one position.
|
||||
const index = new StarNeighbourhood([
|
||||
{ id: 1, x: 0, y: 0, z: 0 },
|
||||
{ id: 2, x: 2.63, y: 0, z: 0 },
|
||||
{ id: 3, x: 2.63, y: 0, z: 0 }
|
||||
]);
|
||||
|
||||
expect(ids(index.nearest(1, 2)).sort()).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
/** 400 stars scattered 20 pc either side of the origin on every axis, so cells on both sides of zero. */
|
||||
function cloud(): StarPoint[] {
|
||||
let seed = 3;
|
||||
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 40 - 20;
|
||||
return Array.from({ length: 400 }, (_, id) => ({ id, x: random(), y: random(), z: random() }));
|
||||
}
|
||||
|
||||
it('visits every star within a radius and no other', () => {
|
||||
const points = cloud();
|
||||
const origin = points[0];
|
||||
const expected = points
|
||||
.filter((point) => point.id !== origin.id && Math.hypot(point.x - origin.x, point.y - origin.y, point.z - origin.z) <= 7)
|
||||
.map((point) => point.id)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const visited: number[] = [];
|
||||
new StarNeighbourhood(points).forEachWithin(origin.id, 7, (neighbour) => visited.push(neighbour.id));
|
||||
|
||||
expect(visited.sort((a, b) => a - b)).toEqual(expected);
|
||||
});
|
||||
|
||||
// The pair walk reads each cell's indices back out of its key; read wrong, it quietly drops
|
||||
// pairs instead of failing.
|
||||
it('walks every pair within a radius exactly once', () => {
|
||||
const points = cloud();
|
||||
let expected = 0;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
for (let j = i + 1; j < points.length; j++) {
|
||||
if (Math.hypot(points[j].x - points[i].x, points[j].y - points[i].y, points[j].z - points[i].z) <= 5) {
|
||||
expected++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const walked = new Set<string>();
|
||||
let visits = 0;
|
||||
new StarNeighbourhood(points).forEachPairWithin(5, (a, b) => {
|
||||
visits++;
|
||||
walked.add(a.id < b.id ? `${a.id}-${b.id}` : `${b.id}-${a.id}`);
|
||||
});
|
||||
|
||||
expect(visits).toBe(expected);
|
||||
expect(walked.size).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Which stars are near which, over the whole catalogue.
|
||||
*
|
||||
* Two questions are asked of the same catalogue and answered here once: "what are the k nearest
|
||||
* stars to this one" (the neighbour labels shown from inside a system) and "which pairs lie
|
||||
* within n parsecs of each other" (the jump-link graph). A linear scan answers the first
|
||||
* acceptably — 68 000 distance tests, once, on entering a system — and the second not at all: a
|
||||
* graph over a few thousand nodes is a few thousand scans, and the quadratic shows.
|
||||
*
|
||||
* So both run on a uniform grid keyed by cell coordinates. The catalogue is a dense blob around
|
||||
* the Sun thinning out to 250 pc, which is exactly the distribution a uniform grid handles
|
||||
* badly in the dense middle and well everywhere else — but the queries are all small radii in
|
||||
* that same dense middle, where a cell holds a handful of stars, so the cost lands where the
|
||||
* answers are. A KD-tree would be tighter and is not yet worth its code.
|
||||
*/
|
||||
|
||||
/** A catalogued star reduced to what proximity needs: an id and a position in parsecs. */
|
||||
export interface StarPoint {
|
||||
readonly id: number;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly z: number;
|
||||
}
|
||||
|
||||
/** A star found near another, with the separation that found it. */
|
||||
export interface Neighbour {
|
||||
readonly id: number;
|
||||
readonly distancePc: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cell edge in parsecs. Sized so a cell in the crowded inner catalogue holds a few dozen stars:
|
||||
* small enough that a 5 pc query touches a handful of cells, large enough that a 250 pc
|
||||
* catalogue does not allocate a map with a million keys.
|
||||
*/
|
||||
const DEFAULT_CELL_SIZE_PC = 5;
|
||||
|
||||
/** Grows the search a shell of cells at a time; the cap stops a query in empty space forever. */
|
||||
const MAX_RING = 12;
|
||||
|
||||
/**
|
||||
* Cells are keyed by one number packed from their three indices rather than by a string. A route
|
||||
* search visits up to 125 cells for every star it expands, and building `"ix,iy,iz"` for each
|
||||
* was half of what a route cost. Room for 65 536 cells either side of the Sun on every axis,
|
||||
* 330 kpc at the default cell size, and the packed key stays inside a double's exact integers.
|
||||
*/
|
||||
const CELL_OFFSET = 65_536;
|
||||
const CELL_SPAN = 131_072;
|
||||
|
||||
function cellKey(ix: number, iy: number, iz: number): number {
|
||||
return ((ix + CELL_OFFSET) * CELL_SPAN + (iy + CELL_OFFSET)) * CELL_SPAN + (iz + CELL_OFFSET);
|
||||
}
|
||||
|
||||
function cellIndices(key: number): [number, number, number] {
|
||||
const iz = (key % CELL_SPAN) - CELL_OFFSET;
|
||||
const rest = Math.floor(key / CELL_SPAN);
|
||||
return [Math.floor(rest / CELL_SPAN) - CELL_OFFSET, (rest % CELL_SPAN) - CELL_OFFSET, iz];
|
||||
}
|
||||
|
||||
export class StarNeighbourhood {
|
||||
private readonly cells = new Map<number, number[]>();
|
||||
private readonly points: readonly StarPoint[];
|
||||
private readonly indexById = new Map<number, number>();
|
||||
private readonly cellSizePc: number;
|
||||
|
||||
constructor(points: readonly StarPoint[], cellSizePc: number = DEFAULT_CELL_SIZE_PC) {
|
||||
this.points = points;
|
||||
this.cellSizePc = cellSizePc > 0 ? cellSizePc : DEFAULT_CELL_SIZE_PC;
|
||||
|
||||
points.forEach((point, index) => {
|
||||
this.indexById.set(point.id, index);
|
||||
const key = this.keyFor(point.x, point.y, point.z);
|
||||
const cell = this.cells.get(key);
|
||||
if (cell) {
|
||||
cell.push(index);
|
||||
} else {
|
||||
this.cells.set(key, [index]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Where the star this id names sits in the list the index was built from, or `undefined`. */
|
||||
indexOf(id: number): number | undefined {
|
||||
return this.indexById.get(id);
|
||||
}
|
||||
|
||||
/** The star at this position in the list the index was built from. */
|
||||
pointAt(index: number): StarPoint {
|
||||
return this.points[index];
|
||||
}
|
||||
|
||||
/** The star this id names, or `undefined` — the caller's id may not be in the catalogue. */
|
||||
point(id: number): StarPoint | undefined {
|
||||
const index = this.indexById.get(id);
|
||||
return index === undefined ? undefined : this.points[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `nearest`, but the stars `prefer` accepts come first, and the rest only fill what is
|
||||
* left. The preferred pass exhausts the search before the fill runs, so a preferred star is
|
||||
* never outranked by an ordinary one that happens to be closer — that is the point of asking.
|
||||
*/
|
||||
nearestPreferring(id: number, count: number, prefer: (point: StarPoint) => boolean): Neighbour[] {
|
||||
const preferred = this.nearest(id, count, prefer);
|
||||
if (preferred.length >= count) {
|
||||
return preferred;
|
||||
}
|
||||
const taken = new Set(preferred.map((neighbour) => neighbour.id));
|
||||
return preferred.concat(this.nearest(id, count - preferred.length, (point) => !taken.has(point.id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The `count` stars nearest to `id`, nearest first, excluding the star itself.
|
||||
*
|
||||
* Searches outward a shell of cells at a time and stops only once the shell it just finished
|
||||
* lies further away than the furthest result held — the ring that contains the kth star can
|
||||
* still be beaten by a closer star in the next ring out, since a cell's near corner is nearer
|
||||
* than its centre.
|
||||
*/
|
||||
nearest(id: number, count: number, filter?: (point: StarPoint) => boolean): Neighbour[] {
|
||||
const origin = this.point(id);
|
||||
if (!origin || count <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const found: Neighbour[] = [];
|
||||
const [ox, oy, oz] = this.cellFor(origin.x, origin.y, origin.z);
|
||||
|
||||
for (let ring = 0; ring <= MAX_RING; ring++) {
|
||||
// Everything in this ring is at least this far away, so once the results already held are
|
||||
// all closer than that, no further ring can improve them.
|
||||
if (found.length >= count && (ring - 1) * this.cellSizePc > found[found.length - 1].distancePc) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const index of this.ringIndices(ox, oy, oz, ring)) {
|
||||
const candidate = this.points[index];
|
||||
if (candidate.id === id || (filter && !filter(candidate))) {
|
||||
continue;
|
||||
}
|
||||
const distancePc = Math.hypot(candidate.x - origin.x, candidate.y - origin.y, candidate.z - origin.z);
|
||||
if (found.length >= count && distancePc >= found[found.length - 1].distancePc) {
|
||||
continue;
|
||||
}
|
||||
// Insertion sort into a list that is never longer than `count`: cheaper than sorting
|
||||
// every candidate the rings turn up, of which there are far more than are kept.
|
||||
const at = found.findIndex((other) => distancePc < other.distancePc);
|
||||
found.splice(at === -1 ? found.length : at, 0, { id: candidate.id, distancePc });
|
||||
if (found.length > count) {
|
||||
found.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every star within `radiusPc` of `id`, nearest first, excluding the star itself. This is what
|
||||
* a jump-link graph is built from: one call per node gives that node's edges.
|
||||
*/
|
||||
within(id: number, radiusPc: number): Neighbour[] {
|
||||
const found: Neighbour[] = [];
|
||||
this.forEachWithin(id, radiusPc, (neighbour, distancePc) => found.push({ id: neighbour.id, distancePc }));
|
||||
found.sort((a, b) => a.distancePc - b.distancePc);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same stars as `within`, handed over one at a time in no particular order. What a search
|
||||
* that expands thousands of stars wants: it has no use for each star's neighbours sorted and
|
||||
* collected into a list, which was the other half of what a route cost.
|
||||
*
|
||||
* A distance is compared as a distance, not as its square, here and in the pair walk: squaring
|
||||
* a range can round it just under the square of the very hop it was read from, and then a
|
||||
* range set to a reported distance would not admit that hop again.
|
||||
*/
|
||||
forEachWithin(id: number, radiusPc: number, visit: (neighbour: StarPoint, distancePc: number) => void): void {
|
||||
const origin = this.point(id);
|
||||
if (!origin || radiusPc <= 0) {
|
||||
return;
|
||||
}
|
||||
const [ox, oy, oz] = this.cellFor(origin.x, origin.y, origin.z);
|
||||
const reach = Math.ceil(radiusPc / this.cellSizePc);
|
||||
|
||||
for (let ix = ox - reach; ix <= ox + reach; ix++) {
|
||||
for (let iy = oy - reach; iy <= oy + reach; iy++) {
|
||||
for (let iz = oz - reach; iz <= oz + reach; iz++) {
|
||||
const cell = this.cells.get(cellKey(ix, iy, iz));
|
||||
if (!cell) {
|
||||
continue;
|
||||
}
|
||||
for (const index of cell) {
|
||||
const candidate = this.points[index];
|
||||
if (candidate.id === id) {
|
||||
continue;
|
||||
}
|
||||
const dx = candidate.x - origin.x;
|
||||
const dy = candidate.y - origin.y;
|
||||
const dz = candidate.z - origin.z;
|
||||
const distancePc = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
if (distancePc <= radiusPc) {
|
||||
visit(candidate, distancePc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visits every pair of stars within `radiusPc` of each other, once per pair.
|
||||
*
|
||||
* The same question `within` answers, asked of the whole catalogue at once — and a different
|
||||
* shape of answer, because asking it star by star is asking it twice per pair and paying for a
|
||||
* sorted list of each star's neighbours that the caller then throws away. Sixty-eight thousand
|
||||
* of those took eight seconds; walking the grid once takes a fraction of it.
|
||||
*
|
||||
* Each cell is paired with itself and with the half of its surrounding cells that lie after it
|
||||
* in the scan, which is what makes each pair come up exactly once.
|
||||
*/
|
||||
forEachPairWithin(radiusPc: number, visit: (a: StarPoint, b: StarPoint, distancePc: number) => void): void {
|
||||
if (radiusPc <= 0) {
|
||||
return;
|
||||
}
|
||||
const reach = Math.ceil(radiusPc / this.cellSizePc);
|
||||
|
||||
for (const [key, cell] of this.cells) {
|
||||
const [ix, iy, iz] = cellIndices(key);
|
||||
for (let dx = 0; dx <= reach; dx++) {
|
||||
for (let dy = dx === 0 ? 0 : -reach; dy <= reach; dy++) {
|
||||
for (let dz = dx === 0 && dy === 0 ? 0 : -reach; dz <= reach; dz++) {
|
||||
const other = dx === 0 && dy === 0 && dz === 0 ? cell : this.cells.get(cellKey(ix + dx, iy + dy, iz + dz));
|
||||
if (!other) {
|
||||
continue;
|
||||
}
|
||||
const sameCell = other === cell;
|
||||
for (let i = 0; i < cell.length; i++) {
|
||||
const a = this.points[cell[i]];
|
||||
// Within one cell, only the pairs after this one; across two, all of them — the
|
||||
// other cell is only ever visited from this side.
|
||||
for (let j = sameCell ? i + 1 : 0; j < other.length; j++) {
|
||||
const b = this.points[other[j]];
|
||||
const dxp = b.x - a.x;
|
||||
const dyp = b.y - a.y;
|
||||
const dzp = b.z - a.z;
|
||||
const distancePc = Math.sqrt(dxp * dxp + dyp * dyp + dzp * dzp);
|
||||
if (distancePc <= radiusPc) {
|
||||
visit(a, b, distancePc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private keyFor(x: number, y: number, z: number): number {
|
||||
const [ix, iy, iz] = this.cellFor(x, y, z);
|
||||
return cellKey(ix, iy, iz);
|
||||
}
|
||||
|
||||
private cellFor(x: number, y: number, z: number): [number, number, number] {
|
||||
return [Math.floor(x / this.cellSizePc), Math.floor(y / this.cellSizePc), Math.floor(z / this.cellSizePc)];
|
||||
}
|
||||
|
||||
/** Indices in the hollow shell of cells exactly `ring` cells out from the centre one. */
|
||||
private *ringIndices(ox: number, oy: number, oz: number, ring: number): Generator<number> {
|
||||
for (let ix = ox - ring; ix <= ox + ring; ix++) {
|
||||
for (let iy = oy - ring; iy <= oy + ring; iy++) {
|
||||
for (let iz = oz - ring; iz <= oz + ring; iz++) {
|
||||
// Only the shell: everything inside it was searched by a previous, smaller ring.
|
||||
const onShell = Math.abs(ix - ox) === ring || Math.abs(iy - oy) === ring || Math.abs(iz - oz) === ring;
|
||||
if (!onShell) {
|
||||
continue;
|
||||
}
|
||||
yield* this.cells.get(cellKey(ix, iy, iz)) ?? [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,18 @@
|
||||
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. */
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const absurd = luminositySolar({ magnitude: -40, distancePc: 5000, spectralType: 'O5V' })!;
|
||||
expect(Number.isFinite(absurd)).toBe(true);
|
||||
@@ -106,3 +210,113 @@ describe('luminositySolar', () => {
|
||||
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.
|
||||
@@ -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. */
|
||||
export interface StellarPhotometry {
|
||||
/** Apparent visual magnitude, as catalogued. */
|
||||
/** Apparent magnitude, as catalogued, in `magnitudeBand`. */
|
||||
magnitude: number;
|
||||
/** Distance from the Sun in parsecs; `0` identifies the Sun itself. */
|
||||
distancePc: number;
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
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 { formatAu, formatDensity, formatLuminosity, formatMassEarth, formatParsecs, formatPeriod, formatRadiusKm, formatTemperature } from './quantity';
|
||||
import { formatAu, formatDensity, formatDistance, formatLuminosity, formatMassEarth, formatParsecs, formatPeriod, formatRadiusKm, formatTemperature } from './quantity';
|
||||
|
||||
describe('formatParsecs', () => {
|
||||
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', () => {
|
||||
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
|
||||
@@ -76,7 +103,11 @@ describe('formatTemperature and formatDensity', () => {
|
||||
describe('formatLuminosity', () => {
|
||||
it('stays decimal across the ordinary range', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -9,7 +9,41 @@
|
||||
|
||||
/** Distance in parsecs, switching to kiloparsecs where the number would otherwise run long. */
|
||||
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. */
|
||||
@@ -62,13 +96,18 @@ export function formatDensity(gramsPerCm3: number): string {
|
||||
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 {
|
||||
if (solar >= 1000 || (solar > 0 && solar < 0.001)) {
|
||||
const exponent = Math.floor(Math.log10(solar));
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { distanceRings, formatRoundLength, roundLengthAtMost, scaleBar } from './scale-bar';
|
||||
|
||||
describe('roundLengthAtMost', () => {
|
||||
it('rounds down to 1, 2 or 5 times a power of ten', () => {
|
||||
expect(roundLengthAtMost(51)).toBe(50);
|
||||
expect(roundLengthAtMost(3.3)).toBe(2);
|
||||
expect(roundLengthAtMost(0.7)).toBe(0.5);
|
||||
expect(roundLengthAtMost(1999)).toBe(1000);
|
||||
});
|
||||
|
||||
it('keeps a length that is already round, including at a decade', () => {
|
||||
expect(roundLengthAtMost(1000)).toBe(1000);
|
||||
expect(roundLengthAtMost(100)).toBe(100);
|
||||
expect(roundLengthAtMost(5)).toBe(5);
|
||||
expect(roundLengthAtMost(0.2)).toBe(0.2);
|
||||
});
|
||||
|
||||
it('has no length for nothing', () => {
|
||||
for (const value of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
expect(roundLengthAtMost(value)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('distanceRings', () => {
|
||||
// The opening view sits about 307 pc from the Sun: the rings the map always had, with the
|
||||
// survey edge at the fifth, and on out past the camera for the stars now drawn beyond it.
|
||||
it('reaches past the camera from the opening view', () => {
|
||||
expect(distanceRings(0, 307, 5, 250)).toEqual([50, 100, 150, 200, 250, 300, 350]);
|
||||
});
|
||||
|
||||
it('closes in with the camera', () => {
|
||||
expect(distanceRings(0, 20, 5, 250)).toEqual([2, 4, 6, 8, 10, 12, 14, 16, 18, 20]);
|
||||
expect(distanceRings(0, 1, 5, 250)).toEqual([0.2, 0.4, 0.6, 0.8, 1]);
|
||||
});
|
||||
|
||||
// Near Mirfak the camera is 155 pc out; rounding the step down to 20 pc must not leave the
|
||||
// rings stopping at 100.
|
||||
it('covers the whole distance whatever the rounding', () => {
|
||||
expect(distanceRings(0, 155, 5, 250)).toEqual([20, 40, 60, 80, 100, 120, 140, 160]);
|
||||
});
|
||||
|
||||
// A star 190 pc out seen from 20 pc away: the frame is a band about 19 pc either side of it and
|
||||
// the Sun is nowhere in it. Sized to the 210 pc it reaches, the step would be 20 pc and the
|
||||
// nearest rings — 180 and 200 — would both miss the frame.
|
||||
it('spaces the rings for a frame that does not hold the Sun', () => {
|
||||
const radii = distanceRings(171, 210, 5, 250);
|
||||
|
||||
expect(radii).toEqual([170, 175, 180, 185, 190, 195, 200, 205, 210]);
|
||||
expect(radii.some((radius) => Math.abs(radius - 190) < 19)).toBe(true);
|
||||
});
|
||||
|
||||
it('marks the callout among rings the step does not land on', () => {
|
||||
expect(distanceRings(0, 1000, 5, 250)).toEqual([200, 250, 400, 600, 800, 1000]);
|
||||
});
|
||||
|
||||
it('leaves the callout out when it is past the last ring, or behind the first', () => {
|
||||
expect(distanceRings(0, 100, 5, 250)).toEqual([20, 40, 60, 80, 100]);
|
||||
// Short of the survey edge by less than one step is not the rule — the last ring is: 245 pc
|
||||
// overshoots to 260 and gets it, 235 pc stops at 240 and does not, on the same 20 pc step.
|
||||
expect(distanceRings(0, 245, 5, 250)).toContain(250);
|
||||
expect(distanceRings(0, 235, 5, 250)).not.toContain(250);
|
||||
expect(distanceRings(400, 440, 5, 250)).toEqual([400, 405, 410, 415, 420, 425, 430, 435, 440]);
|
||||
});
|
||||
|
||||
it('draws no rings for a camera with no distance', () => {
|
||||
expect(distanceRings(0, 0, 5, 250)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scaleBar', () => {
|
||||
it('picks the longest round length that fits, and the width it spans', () => {
|
||||
// A tenth of a parsec a pixel and 120 px of room: 12 pc would fit, and the round length
|
||||
// under it is 10 pc, which spans 100 px.
|
||||
expect(scaleBar(0.1, 120, 'pc')).toEqual({ label: '10 pc', widthPx: 100 });
|
||||
});
|
||||
|
||||
it('draws nothing for a view with no extent', () => {
|
||||
expect(scaleBar(0, 120, 'pc')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRoundLength', () => {
|
||||
it('reads without trailing zeros, in kiloparsecs past a thousand', () => {
|
||||
expect(formatRoundLength(2000, 'pc')).toBe('2 kpc');
|
||||
expect(formatRoundLength(500, 'pc')).toBe('500 pc');
|
||||
expect(formatRoundLength(0.2, 'pc')).toBe('0.2 pc');
|
||||
expect(formatRoundLength(0.05, 'AU')).toBe('0.05 AU');
|
||||
});
|
||||
|
||||
// Ring radii are multiples of a round step rather than round themselves; a one-digit format
|
||||
// printed the 250 pc survey edge as "300 pc".
|
||||
it('keeps every digit of a ring radius', () => {
|
||||
expect(formatRoundLength(250, 'pc')).toBe('250 pc');
|
||||
expect(formatRoundLength(150, 'pc')).toBe('150 pc');
|
||||
expect(formatRoundLength(2500, 'pc')).toBe('2.5 kpc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* The round lengths a map is read against: its scale bar, and the spacing of its distance rings.
|
||||
*
|
||||
* Round means 1, 2 or 5 times a power of ten — the only lengths a reader can add up at a glance,
|
||||
* which is why every printed map's scale bar uses them.
|
||||
*/
|
||||
|
||||
/** The largest 1, 2 or 5 × a power of ten that is at most `value`, or `null` for no length at all. */
|
||||
export function roundLengthAtMost(value: number): number | null {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
const power = 10 ** Math.floor(Math.log10(value));
|
||||
const mantissa = value / power;
|
||||
return (mantissa >= 5 ? 5 : mantissa >= 2 ? 2 : 1) * power;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rings across the span from `nearest` to `reach`, at a round step of about a `count`th of it,
|
||||
* plus `callout` where it falls between the first ring and the last: the grid's own radii are
|
||||
* round, and the one radius that means something in its own right is marked whether the step lands
|
||||
* on it or not. A frame that stops short of it gets it only when the last ring — the first multiple
|
||||
* of `step` at or past `reach` — is past it: reach 245 with a 20 pc step gets it, reach 235 does
|
||||
* not, since its last ring is 240.
|
||||
*
|
||||
* Two numbers rather than one because these rings are centred on a fixed point — the Sun — and a
|
||||
* frame need not be. Looking at something 200 pc out from 20 pc away, what is on screen is a band
|
||||
* 200 pc wide at its narrowest and nowhere near the Sun; a step sized to the whole 220 puts every
|
||||
* ring off the frame. The span is what the frame covers, so the step is what it can resolve.
|
||||
*
|
||||
* Rounding the step down, over a span that need not start at the Sun, makes for `count` to
|
||||
* `ceil(2.5 × count) + 2` rings — `ceil(reach / step) - floor(nearest / step) + 1` — and the
|
||||
* callout can add one: 5 to 16 for a count of 5.
|
||||
*/
|
||||
export function distanceRings(nearest: number, reach: number, count: number, callout: number): number[] {
|
||||
const step = roundLengthAtMost((reach - nearest) / count);
|
||||
if (step === null) {
|
||||
return [];
|
||||
}
|
||||
// The ring just inside the near edge of the span, so the band is crossed rather than started at.
|
||||
const first = Math.max(1, Math.floor(nearest / step));
|
||||
const last = Math.ceil(reach / step);
|
||||
// `toPrecision` clears the binary noise of stepping by a tenth: 0.1 × 3 is 0.30000000000000004.
|
||||
const radii = Array.from({ length: last - first + 1 }, (_, index) => Number((step * (first + index)).toPrecision(12)));
|
||||
if (callout > radii[0] && callout < radii[radii.length - 1] && !radii.includes(callout)) {
|
||||
radii.push(callout);
|
||||
radii.sort((a, b) => a - b);
|
||||
}
|
||||
return radii;
|
||||
}
|
||||
|
||||
export type LengthUnit = 'pc' | 'AU';
|
||||
|
||||
/** A round length, and how many pixels it spans at the current zoom. */
|
||||
export interface ScaleBar {
|
||||
readonly label: string;
|
||||
readonly widthPx: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The longest round length that fits in `maxWidthPx` when one pixel spans `unitsPerPx`.
|
||||
*
|
||||
* Under a perspective camera a pixel spans a different length at every depth, so the scene
|
||||
* measures `unitsPerPx` at the point the view is centred on, which is where the map is being
|
||||
* read. Under the plan view it is exact everywhere.
|
||||
*/
|
||||
export function scaleBar(unitsPerPx: number, maxWidthPx: number, unit: LengthUnit): ScaleBar | null {
|
||||
const length = roundLengthAtMost(unitsPerPx * maxWidthPx);
|
||||
if (length === null) {
|
||||
return null;
|
||||
}
|
||||
return { label: formatRoundLength(length, unit), widthPx: length / unitsPerPx };
|
||||
}
|
||||
|
||||
/** A scale or ring length, in kiloparsecs past a thousand parsecs. */
|
||||
export function formatRoundLength(length: number, unit: LengthUnit): string {
|
||||
if (unit === 'pc' && length >= 1000) {
|
||||
return `${digitsOf(length / 1000)} kpc`;
|
||||
}
|
||||
return `${digitsOf(length)} ${unit}`;
|
||||
}
|
||||
|
||||
/** `0.05`, `2`, `150`, never `2.00`: these lengths have no digits past the ones that carry them. */
|
||||
function digitsOf(value: number): string {
|
||||
return String(Number(value.toPrecision(3)));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user