Compare commits
108
Commits
feat/hud-restyle
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
822c69944c | ||
|
|
f2608f1172 | ||
|
|
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 | ||
|
|
9ae87e83fb |
@@ -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,21 @@ 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. 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
|
||||
key: gaia-dr3-${{ 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.
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
@@ -12,8 +12,10 @@ import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBO
|
||||
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 { 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';
|
||||
@@ -37,14 +39,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,10 +58,23 @@ const GLOW_SCALE = 2.6;
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
<!-- Search and what has been kept: there is no scene readout here, the info panel is
|
||||
the reading, and the panel's own control is what keeps this body. -->
|
||||
<app-hud-dock (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;
|
||||
@@ -84,6 +99,7 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly engine: EngineService,
|
||||
private readonly dataLoader: DataLoaderService,
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly router: Router,
|
||||
private readonly navigationStore: NavigationStore
|
||||
) {}
|
||||
|
||||
|
||||
@@ -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,105 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
|
||||
System
|
||||
</button>
|
||||
|
||||
<header class="px-4 pt-4 pb-3">
|
||||
<header class="flex items-start gap-2 px-4 pt-4 pb-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<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>
|
||||
</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
|
||||
) {}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, MockInstance, vi } from 'vitest';
|
||||
|
||||
import { DataLoaderService, StarField } from '../../core/data/data-loader.service';
|
||||
import { EngineService, EngineTickCallback } from '../../core/engine/engine.service';
|
||||
@@ -10,7 +10,13 @@ import { DeepSkyRecord } from '../../shared/models/deepsky.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
import { LinkBudget } from '../../shared/astro/jump-links';
|
||||
import { HudDisplay } from '../hud/hud-dock.component';
|
||||
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
|
||||
import { galacticNormal } from './grid-plane';
|
||||
import { JumpLinkRenderer } from './jump-link-renderer';
|
||||
import { StarFieldRenderer } from './star-field-renderer';
|
||||
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
|
||||
|
||||
// jsdom does not implement ResizeObserver; the component only uses it to react to real
|
||||
// layout changes, which never happen in this headless test.
|
||||
@@ -22,7 +28,8 @@ import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
|
||||
|
||||
const SUN: StarRecord = { id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 };
|
||||
const ALPHA_CENTAURI: StarRecord = { id: 1, name: 'Alpha Centauri', x: 1.34, y: 0, z: 0, magnitude: 4.4, spectralType: 'G2V', colorIndex: 0.7 };
|
||||
const PROXIMA: StarRecord = { id: 2, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 };
|
||||
// Its id deliberately differs from its place in STARS, so a lookup by id cannot pass for one by index.
|
||||
const PROXIMA: StarRecord = { id: 42, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 };
|
||||
|
||||
const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA];
|
||||
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
|
||||
@@ -64,7 +71,9 @@ const EARTH: BodyRecord = {
|
||||
class FakeEngineService {
|
||||
private readonly scene = new THREE.Scene();
|
||||
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
|
||||
private readonly orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
|
||||
private readonly tickCallbacks = new Set<EngineTickCallback>();
|
||||
projection: 'perspective' | 'orthographic' = 'perspective';
|
||||
|
||||
get isInitialized(): boolean {
|
||||
return true;
|
||||
@@ -78,10 +87,41 @@ class FakeEngineService {
|
||||
return this.scene;
|
||||
}
|
||||
|
||||
getCamera(): THREE.PerspectiveCamera {
|
||||
getCamera(): THREE.PerspectiveCamera | THREE.OrthographicCamera {
|
||||
return this.projection === 'orthographic' ? this.orthographic : this.camera;
|
||||
}
|
||||
|
||||
getPerspectiveCamera(): THREE.PerspectiveCamera {
|
||||
return this.camera;
|
||||
}
|
||||
|
||||
get currentProjection(): 'perspective' | 'orthographic' {
|
||||
return this.projection;
|
||||
}
|
||||
|
||||
setProjection(projection: 'perspective' | 'orthographic', distanceToTarget: number): void {
|
||||
this.projection = projection;
|
||||
this.orthographic.zoom = 1;
|
||||
this.orthographic.position.copy(this.camera.position);
|
||||
this.orthographic.quaternion.copy(this.camera.quaternion);
|
||||
this.frameOrthographic(distanceToTarget);
|
||||
}
|
||||
|
||||
frameOrthographic(distanceToTarget: number): void {
|
||||
const halfHeight = Math.max(distanceToTarget, 1e-6) * Math.tan((this.camera.fov * Math.PI) / 360);
|
||||
this.orthographic.top = halfHeight;
|
||||
this.orthographic.bottom = -halfHeight;
|
||||
this.orthographic.left = -halfHeight * this.camera.aspect;
|
||||
this.orthographic.right = halfHeight * this.camera.aspect;
|
||||
this.orthographic.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
visibleHalfHeight(distanceToTarget: number): number {
|
||||
return this.projection === 'orthographic'
|
||||
? (this.orthographic.top - this.orthographic.bottom) / (2 * this.orthographic.zoom)
|
||||
: distanceToTarget * Math.tan((this.camera.fov * Math.PI) / 360);
|
||||
}
|
||||
|
||||
onTick(callback: EngineTickCallback): () => void {
|
||||
this.tickCallbacks.add(callback);
|
||||
return () => this.tickCallbacks.delete(callback);
|
||||
@@ -95,6 +135,13 @@ class FakeEngineService {
|
||||
|
||||
resize(): void {}
|
||||
|
||||
/** The canvas's device pixels per CSS pixel, as the renderer was told. */
|
||||
pixelRatio = 1;
|
||||
|
||||
getRenderer(): { getPixelRatio(): number } {
|
||||
return { getPixelRatio: () => this.pixelRatio };
|
||||
}
|
||||
|
||||
/** Test helper: simulates one rendered frame by invoking every registered tick callback. */
|
||||
tick(deltaSeconds: number): void {
|
||||
for (const callback of this.tickCallbacks) {
|
||||
@@ -171,6 +218,602 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
expect(navigationStore.viewLevel()).toBe('galaxy');
|
||||
});
|
||||
|
||||
it('clears a selection the catalogue no longer holds instead of chasing it', async () => {
|
||||
// A bookmark saved against a Gaia row id that the next refresh renumbered. Before the guard,
|
||||
// entering the missing system completed at once, completion re-read the same id, and the
|
||||
// two recursed until the stack overflowed.
|
||||
navigationStore.selectStar(987654321);
|
||||
await flushAsync();
|
||||
|
||||
expect(navigationStore.selectedStarId()).toBeNull();
|
||||
expect(navigationStore.viewLevel()).toBe('galaxy');
|
||||
});
|
||||
|
||||
it('chooses the drawn stars again once the view centre has moved, and not for a small drift', async () => {
|
||||
const component = fixture.componentInstance as unknown as { controls: { target: THREE.Vector3 } };
|
||||
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
|
||||
// The first pass always chooses; what is under test is the move after it.
|
||||
await advanceFrames(engine, 0.3);
|
||||
refocus.mockClear();
|
||||
|
||||
component.controls.target.set(40, 0, 0);
|
||||
await advanceFrames(engine, 0.3);
|
||||
expect(refocus).toHaveBeenCalledTimes(1);
|
||||
expect(refocus.mock.calls[0][0].centre).toMatchObject({ x: 40, y: 0, z: 0 });
|
||||
|
||||
component.controls.target.set(42, 0, 0);
|
||||
await advanceFrames(engine, 0.3);
|
||||
expect(refocus).toHaveBeenCalledTimes(1);
|
||||
refocus.mockRestore();
|
||||
});
|
||||
|
||||
describe('the drawn stars, chosen for what the camera shows', () => {
|
||||
type ViewScene = { controls: { target: THREE.Vector3; update(): void }; display: { update(change: (display: HudDisplay) => HudDisplay): void } };
|
||||
let refocus: MockInstance<StarFieldRenderer['refocus']>;
|
||||
|
||||
beforeEach(() => {
|
||||
refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
|
||||
});
|
||||
afterEach(() => refocus.mockRestore());
|
||||
|
||||
/** Swings the camera about the view's centre, around the scene's vertical, by `degrees`. */
|
||||
function orbit(component: ViewScene, degrees: number): void {
|
||||
const camera = engine.getCamera();
|
||||
const target = component.controls.target;
|
||||
camera.position.sub(target).applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(degrees)).add(target);
|
||||
component.controls.update();
|
||||
}
|
||||
|
||||
it('chooses them for the opening view on the first pass, planet hosts included', async () => {
|
||||
await advanceFrames(engine, 0.6);
|
||||
|
||||
expect(refocus).toHaveBeenCalledTimes(1);
|
||||
const [focus] = refocus.mock.calls[0];
|
||||
expect(focus.view).toBeDefined();
|
||||
// The Sun has Earth, so it is a host; the others have nothing catalogued.
|
||||
expect(Array.from(focus.hosts ?? [])).toEqual([1, 0, 0]);
|
||||
});
|
||||
|
||||
it('chooses again once the camera has turned half the margin, and not for less', async () => {
|
||||
const component = fixture.componentInstance as unknown as ViewScene;
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
orbit(component, 1);
|
||||
await advanceFrames(engine, 0.3);
|
||||
expect(refocus).toHaveBeenCalledTimes(1);
|
||||
|
||||
orbit(component, 3);
|
||||
await advanceFrames(engine, 0.3);
|
||||
expect(refocus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('chooses again once a pan has moved the view further than a fifth of the neighbourhood, and not for less', async () => {
|
||||
const component = fixture.componentInstance as unknown as ViewScene;
|
||||
const camera = engine.getCamera();
|
||||
// Camera and centre together, so the camera neither turns nor zooms.
|
||||
const pan = (pc: number) => {
|
||||
component.controls.target.x += pc;
|
||||
camera.position.x += pc;
|
||||
component.controls.update();
|
||||
};
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
pan(3);
|
||||
await advanceFrames(engine, 0.3);
|
||||
expect(refocus).toHaveBeenCalledTimes(1);
|
||||
|
||||
pan(3);
|
||||
await advanceFrames(engine, 0.3);
|
||||
expect(refocus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('chooses again once a zoom has changed the frame by half the margin, and not for less', async () => {
|
||||
const component = fixture.componentInstance as unknown as ViewScene;
|
||||
const camera = engine.getCamera();
|
||||
const dolly = (factor: number) => camera.position.sub(component.controls.target).multiplyScalar(factor).add(component.controls.target);
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
dolly(0.95);
|
||||
await advanceFrames(engine, 0.3);
|
||||
expect(refocus).toHaveBeenCalledTimes(1);
|
||||
|
||||
dolly(0.8);
|
||||
await advanceFrames(engine, 0.3);
|
||||
expect(refocus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('chooses again for the plan view, where a small turn moves deep stars furthest', async () => {
|
||||
const component = fixture.componentInstance as unknown as ViewScene;
|
||||
// About 10 pc of frame either side of the centre.
|
||||
engine.getCamera().position.setLength(21.4);
|
||||
component.controls.update();
|
||||
await advanceFrames(engine, 0.3);
|
||||
const beforePlan = refocus.mock.calls.length;
|
||||
|
||||
component.display.update((display) => ({ ...display, plan: true }));
|
||||
TestBed.tick();
|
||||
await advanceFrames(engine, 0.3);
|
||||
expect(refocus).toHaveBeenCalledTimes(beforePlan + 1);
|
||||
|
||||
// Harmless under perspective; under the plan it moves a star 250 pc deep by 4 pc, against a 2.5 pc margin.
|
||||
orbit(component, 1);
|
||||
await advanceFrames(engine, 0.3);
|
||||
expect(refocus).toHaveBeenCalledTimes(beforePlan + 2);
|
||||
});
|
||||
|
||||
it('chooses again when the projection changes under a pose that has not moved at all', async () => {
|
||||
await advanceFrames(engine, 0.3);
|
||||
const before = refocus.mock.calls.length;
|
||||
// The same place, direction and frame height, but a box instead of a frustum, which frames other stars.
|
||||
const perspective = engine.getPerspectiveCamera();
|
||||
const plan = (engine as unknown as { orthographic: THREE.OrthographicCamera }).orthographic;
|
||||
plan.position.copy(perspective.position);
|
||||
plan.quaternion.copy(perspective.quaternion);
|
||||
engine.projection = 'orthographic';
|
||||
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
expect(refocus).toHaveBeenCalledTimes(before + 1);
|
||||
});
|
||||
|
||||
it('holds a turn to the narrower side of a portrait frame', async () => {
|
||||
const component = fixture.componentInstance as unknown as ViewScene;
|
||||
engine.getPerspectiveCamera().aspect = 0.4;
|
||||
engine.getPerspectiveCamera().updateProjectionMatrix();
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
// Inside half the margin above and below, past half of it at the sides.
|
||||
orbit(component, 2);
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
expect(refocus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps up with a flight frame by frame, from the frame it comes back into parsec space', async () => {
|
||||
const component = fixture.componentInstance as unknown as ViewScene & { galaxyGroup: THREE.Group; rig: { isAnimating: boolean } };
|
||||
navigationStore.selectStar(SUN.id);
|
||||
await flushAsync();
|
||||
await advanceFrames(engine, 2.5);
|
||||
refocus.mockClear();
|
||||
|
||||
navigationStore.selectStar(null);
|
||||
await flushAsync();
|
||||
let choicesOnReturningFrame = -1;
|
||||
let flightFrames = 0;
|
||||
let flightChoices = 0;
|
||||
for (let frame = 0; frame < 80; frame++) {
|
||||
const wasInSystem = !component.galaxyGroup.visible;
|
||||
const before = refocus.mock.calls.length;
|
||||
engine.tick(0.05);
|
||||
await flushAsync(1);
|
||||
if (wasInSystem && component.galaxyGroup.visible) {
|
||||
choicesOnReturningFrame = refocus.mock.calls.length - before;
|
||||
}
|
||||
if (component.galaxyGroup.visible && component.rig.isAnimating) {
|
||||
flightFrames++;
|
||||
flightChoices += refocus.mock.calls.length - before;
|
||||
}
|
||||
}
|
||||
|
||||
// Chosen for the view in the very frame the camera jumps back, not up to a pass later.
|
||||
expect(choicesOnReturningFrame).toBe(1);
|
||||
// The return zooms out from inside the system to the opening view: more re-choices than one a
|
||||
// pass could make, and every one of them for the view.
|
||||
expect(flightChoices).toBeGreaterThan(Math.ceil((flightFrames * 0.05) / 0.2));
|
||||
expect(refocus.mock.calls.every(([focus]) => focus.view !== undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('chooses once for the whole sky on the way out to the Galaxy, then leaves them alone', async () => {
|
||||
const component = fixture.componentInstance as unknown as ViewScene;
|
||||
engine.getCamera().position.set(0, 0, 30000);
|
||||
await advanceFrames(engine, 0.3);
|
||||
const onArrival = refocus.mock.calls.length;
|
||||
expect(refocus.mock.calls.at(-1)![0].view).toBeUndefined();
|
||||
|
||||
component.controls.target.set(500, 0, 0);
|
||||
await advanceFrames(engine, 0.3);
|
||||
component.controls.target.set(1500, 0, 0);
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
expect(refocus).toHaveBeenCalledTimes(onArrival);
|
||||
expect(refocus.mock.calls.filter(([focus]) => focus.view === undefined)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the local grid of distance rings', () => {
|
||||
type GridScene = {
|
||||
controls: { target: THREE.Vector3; update(): void };
|
||||
display: { update(change: (display: HudDisplay) => HudDisplay): void };
|
||||
localGridRadii: readonly number[];
|
||||
};
|
||||
|
||||
it('sizes the rings by how far the frame reaches from the Sun, under either projection', async () => {
|
||||
const component = fixture.componentInstance as unknown as GridScene;
|
||||
const camera = engine.getCamera();
|
||||
// Centred on a point 200 pc out along the galactic plane — where the rings are — seen from
|
||||
// 20 pc above it. The rings have to reach it, and one of them has to cross the frame.
|
||||
const normal = galacticNormal();
|
||||
const centre = new THREE.Vector3(1, 0, 0).projectOnPlane(normal).normalize().multiplyScalar(200);
|
||||
component.controls.target.copy(centre);
|
||||
camera.position.copy(centre).addScaledVector(normal, 20);
|
||||
component.controls.update();
|
||||
await advanceFrames(engine, 0.3);
|
||||
const underPerspective = [...component.localGridRadii];
|
||||
|
||||
component.display.update((display) => ({ ...display, plan: true }));
|
||||
TestBed.tick();
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
expect(underPerspective.at(-1)).toBeGreaterThanOrEqual(200);
|
||||
// The frame is a band about 19 pc either side of 200 pc: rings out to 220 at a step sized to
|
||||
// all 220 are 180 and 200, both of them off screen.
|
||||
const halfHeight = engine.visibleHalfHeight(20);
|
||||
expect(underPerspective.some((radius) => Math.abs(radius - 200) < halfHeight)).toBe(true);
|
||||
// The plan view's wheel moves the frame rather than the camera, so "how far out the camera
|
||||
// is" means something else there; what the rings have to cover does not.
|
||||
expect([...component.localGridRadii]).toEqual(underPerspective);
|
||||
});
|
||||
|
||||
it('measures the span in the plane the rings lie in, not through it', async () => {
|
||||
const component = fixture.componentInstance as unknown as GridScene;
|
||||
const camera = engine.getCamera();
|
||||
// The same 200 pc out along the plane, but lifted 150 pc above it: 250 pc from the Sun as the
|
||||
// crow flies, and still 200 pc out among the rings, which is the distance they are drawn at.
|
||||
const normal = galacticNormal();
|
||||
const centre = new THREE.Vector3(1, 0, 0).projectOnPlane(normal).normalize().multiplyScalar(200).addScaledVector(normal, 150);
|
||||
component.controls.target.copy(centre);
|
||||
camera.position.copy(centre).addScaledVector(normal, 20);
|
||||
component.controls.update();
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
const halfHeight = engine.visibleHalfHeight(20);
|
||||
expect([...component.localGridRadii].some((radius) => Math.abs(radius - 200) < halfHeight)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the rings alone while the grid is not drawn', async () => {
|
||||
const component = fixture.componentInstance as unknown as GridScene;
|
||||
const camera = engine.getCamera();
|
||||
await advanceFrames(engine, 0.3);
|
||||
component.display.update((display) => ({ ...display, grid: false }));
|
||||
TestBed.tick();
|
||||
await advanceFrames(engine, 0.3);
|
||||
const hidden = [...component.localGridRadii];
|
||||
|
||||
// A zoom this size crosses two round steps, and each crossing rebuilds every ring's vertices.
|
||||
camera.position.setLength(camera.position.length() / 8);
|
||||
component.controls.update();
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
expect([...component.localGridRadii]).toEqual(hidden);
|
||||
});
|
||||
|
||||
it('drops a ring label that a star name has taken, or that is off screen, and keeps the ladder otherwise', () => {
|
||||
const component = fixture.componentInstance as unknown as {
|
||||
ringLabelsInTheClear(candidates: readonly LabeledPoint[], camera: THREE.Camera, stars: readonly LabeledPoint[]): LabeledPoint[];
|
||||
};
|
||||
const camera = engine.getCamera();
|
||||
camera.updateMatrixWorld(true);
|
||||
const at = (x: number, y: number) => new THREE.Vector3(x, y, 0.5).unproject(camera);
|
||||
// Rungs at a twentieth of the screen: well inside the separation two names would keep, and
|
||||
// well outside the clearance a ring label keeps from a name, so neither test is a coin toss.
|
||||
const near = at(0.1, 0.1);
|
||||
const nextRungUp = at(0.1, 0.18);
|
||||
const offScreen = at(1.6, 0.1);
|
||||
const ladder: LabeledPoint[] = [
|
||||
{ id: 'ring-50', name: '50 pc', x: near.x, y: near.y, z: near.z },
|
||||
{ id: 'ring-100', name: '100 pc', x: nextRungUp.x, y: nextRungUp.y, z: nextRungUp.z },
|
||||
{ id: 'ring-150', name: '150 pc', x: offScreen.x, y: offScreen.y, z: offScreen.z }
|
||||
];
|
||||
|
||||
// A ladder of rings stays whole, though its rungs are closer than two star names would be.
|
||||
expect(component.ringLabelsInTheClear(ladder, camera, []).map((label) => label.id)).toEqual(['ring-50', 'ring-100']);
|
||||
// A star's name is worth more than a distance.
|
||||
const star: LabeledPoint = { id: 7, name: 'Sirius', x: near.x, y: near.y, z: near.z };
|
||||
expect(component.ringLabelsInTheClear(ladder, camera, [star]).map((label) => label.id)).toEqual(['ring-100']);
|
||||
});
|
||||
|
||||
it('stays out of the text of a name, not just off its point', () => {
|
||||
const component = fixture.componentInstance as unknown as {
|
||||
ringLabelsInTheClear(candidates: readonly LabeledPoint[], camera: THREE.Camera, stars: readonly LabeledPoint[]): LabeledPoint[];
|
||||
viewportAspect(): number;
|
||||
};
|
||||
const camera = engine.getCamera();
|
||||
camera.updateMatrixWorld(true);
|
||||
const aspect = component.viewportAspect();
|
||||
const at = (x: number, y: number) => new THREE.Vector3(x / aspect, y, 0.5).unproject(camera);
|
||||
// A hand's breadth apart on screen — past any clearance around the point — and on the same
|
||||
// line, with the name's text running right through where the ring label starts.
|
||||
const ring = at(0.125, -0.123);
|
||||
const rung: LabeledPoint = { id: 'ring-50', name: '50 pc', x: ring.x, y: ring.y, z: ring.z };
|
||||
const beside = at(0.06, -0.12);
|
||||
const rightHand: LabeledPoint = { id: 7, name: 'Alpha Centauri', side: 'right', x: beside.x, y: beside.y, z: beside.z };
|
||||
|
||||
expect(component.ringLabelsInTheClear([rung], camera, [rightHand])).toEqual([]);
|
||||
// The same name hanging the other way leaves that space empty, and the rung with it.
|
||||
expect(component.ringLabelsInTheClear([rung], camera, [{ ...rightHand, side: 'left' }])).toEqual([rung]);
|
||||
|
||||
// And a rung to the left of a name keeps its place: "50 pc" is a third of a star name's
|
||||
// width, so it ends well before the name starts, whatever the anchors' spacing suggests.
|
||||
const centred = at(0, 0);
|
||||
const spanning: LabeledPoint = { id: 8, name: 'Alnitak', side: 'right', x: centred.x, y: centred.y, z: centred.z };
|
||||
const toTheLeft = at(-0.25, 0.02);
|
||||
const clearRung: LabeledPoint = { id: 'ring-100', name: '100 pc', x: toTheLeft.x, y: toTheLeft.y, z: toTheLeft.z };
|
||||
|
||||
expect(component.ringLabelsInTheClear([clearRung], camera, [spanning])).toEqual([clearRung]);
|
||||
});
|
||||
|
||||
it('places the ring labels with the star names rather than over them', async () => {
|
||||
const component = fixture.componentInstance as unknown as GridScene;
|
||||
const update = vi.spyOn(StarLabelOverlay.prototype, 'update');
|
||||
const cleared = vi.spyOn(GalaxySystemSceneComponent.prototype as unknown as { ringLabelsInTheClear: (...args: unknown[]) => LabeledPoint[] }, 'ringLabelsInTheClear');
|
||||
const camera = engine.getCamera();
|
||||
camera.position.set(0, 4, 10);
|
||||
component.controls.target.set(0, 0, 0);
|
||||
component.controls.update();
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
const labels = (update.mock.calls.at(-1)?.[0] ?? []) as LabeledPoint[];
|
||||
const rings = labels.filter((label) => String(label.id).startsWith('ring-'));
|
||||
expect(rings.length).toBeGreaterThan(0);
|
||||
// Handed over as the clearing pass left them, not as the grid produced them.
|
||||
expect(cleared).toHaveBeenCalled();
|
||||
expect(rings).toEqual(cleared.mock.results.at(-1)?.value);
|
||||
update.mockRestore();
|
||||
cleared.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the stars of a plotted route drawn, and the selected star', async () => {
|
||||
const component = fixture.componentInstance as unknown as { routeResult: { set(value: unknown): void } };
|
||||
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }, { id: PROXIMA.id, name: 'Proxima Centauri' }], totalPc: 1.3, neededRangePc: null, gaveUp: false, least: true });
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
// As catalogue indices: the Sun is the first entry of STARS, Proxima the third.
|
||||
expect(refocus.mock.calls.at(-1)![0].pinned).toEqual([0, 2]);
|
||||
refocus.mockRestore();
|
||||
});
|
||||
|
||||
describe('the jump-link graph', () => {
|
||||
type LinkScene = {
|
||||
routing: { links(rangePc: number, drawn: Uint32Array, budget?: LinkBudget): Promise<Float32Array>; route(): Promise<never>; dispose(): void };
|
||||
display: { update(change: (display: { jumpLinks: boolean }) => unknown): void };
|
||||
jumpRangePc: { set(rangePc: number): void };
|
||||
routeResult: { set(value: unknown): void };
|
||||
controls: { target: THREE.Vector3 };
|
||||
starField: { drawnStars: Uint32Array; drawn: Uint32Array };
|
||||
};
|
||||
/** Real time, since the rebuild waits on a real timer for the range and the drawn stars to settle. */
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
function linkScene(links: LinkScene['routing']['links']): LinkScene {
|
||||
const component = fixture.componentInstance as unknown as LinkScene;
|
||||
component.routing = { links, route: () => new Promise<never>(() => undefined), dispose: () => undefined };
|
||||
component.display.update((display) => ({ ...display, jumpLinks: true }));
|
||||
TestBed.tick();
|
||||
return component;
|
||||
}
|
||||
|
||||
/** Makes the next refocus choose a different set: the field is told it draws one star, then the view moves. */
|
||||
async function changeDrawnStars(component: LinkScene, targetX: number): Promise<void> {
|
||||
component.starField.drawn = Uint32Array.of(0);
|
||||
component.controls.target.set(targetX, 0, 0);
|
||||
await advanceFrames(engine, 0.3);
|
||||
}
|
||||
|
||||
it('links the stars being drawn, and asks again once a new set of them holds still', async () => {
|
||||
const links = vi.fn((_rangePc: number, _drawn: Uint32Array) => Promise.resolve(new Float32Array(0)));
|
||||
const component = linkScene(links);
|
||||
await settle();
|
||||
expect(links).toHaveBeenCalledTimes(1);
|
||||
expect(links.mock.calls[0].slice(0, 2)).toEqual([3, component.starField.drawnStars]);
|
||||
|
||||
await changeDrawnStars(component, 40);
|
||||
expect(links).toHaveBeenCalledTimes(1);
|
||||
await settle();
|
||||
expect(links).toHaveBeenCalledTimes(2);
|
||||
expect(links.mock.calls[1][1]).toBe(component.starField.drawnStars);
|
||||
expect(links.mock.calls[1][1]).not.toBe(links.mock.calls[0][1]);
|
||||
|
||||
// A route re-chooses the drawn stars around its pins, and here they come out the same: no new graph.
|
||||
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }], totalPc: 0, neededRangePc: null, gaveUp: false, least: true });
|
||||
await advanceFrames(engine, 0.3);
|
||||
await settle();
|
||||
expect(links).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('asks for as much of the graph as a million pixels of line make, around where the view is centred', async () => {
|
||||
const links = vi.fn((_rangePc: number, _drawn: Uint32Array, _budget?: LinkBudget) => Promise.resolve(new Float32Array(0)));
|
||||
Object.defineProperty((fixture.nativeElement as HTMLElement).querySelector('canvas')!, 'clientHeight', { value: 1080 });
|
||||
// A screen scaled to 200%: 1080 CSS pixels are 2160 drawn ones, and the lines are drawn in those.
|
||||
engine.pixelRatio = 2;
|
||||
linkScene(links);
|
||||
await settle();
|
||||
|
||||
const budget = links.mock.calls[0][2];
|
||||
// The view opens centred on the Sun: its frame's half-height there, over 1080 drawn pixels, is a pixel's worth of parsecs.
|
||||
const halfHeight = engine.getCamera().position.length() * Math.tan((50 * Math.PI) / 360);
|
||||
expect(budget?.centre).toEqual({ x: 0, y: 0, z: 0 });
|
||||
expect(budget?.lengthPc).toBeCloseTo((1_000_000 * halfHeight) / 1080, 3);
|
||||
});
|
||||
|
||||
it('asks again once the view has zoomed past the budget it asked with, though the drawn stars are the same', async () => {
|
||||
// All three stars fit the star budget, so the drawn set never changes: only the budget can.
|
||||
const links = vi.fn((_rangePc: number, _drawn: Uint32Array, _budget?: LinkBudget) => Promise.resolve(new Float32Array(0)));
|
||||
Object.defineProperty((fixture.nativeElement as HTMLElement).querySelector('canvas')!, 'clientHeight', { value: 1080 });
|
||||
const component = linkScene(links);
|
||||
await advanceFrames(engine, 0.3);
|
||||
await settle();
|
||||
const asked = links.mock.calls.length;
|
||||
|
||||
const camera = engine.getCamera();
|
||||
camera.position.sub(component.controls.target).multiplyScalar(0.5).add(component.controls.target);
|
||||
await advanceFrames(engine, 0.3);
|
||||
await settle();
|
||||
|
||||
expect(links.mock.calls.length).toBe(asked + 1);
|
||||
expect(links.mock.calls.at(-1)![1]).toBe(links.mock.calls[0][1]);
|
||||
});
|
||||
|
||||
it('asks for no graph from inside a system, where distances are in astronomical units', async () => {
|
||||
const links = vi.fn((_rangePc: number, _drawn: Uint32Array, _budget?: LinkBudget) => Promise.resolve(new Float32Array(0)));
|
||||
navigationStore.selectStar(SUN.id);
|
||||
await flushAsync();
|
||||
await advanceFrames(engine, 2.5);
|
||||
|
||||
linkScene(links);
|
||||
await settle();
|
||||
|
||||
expect(links).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps what it asked for when an older request it replaced is rejected', async () => {
|
||||
// Off and on again while a graph is still waiting: the waiting one is replaced, and its
|
||||
// rejection must not be taken for the request that replaced it.
|
||||
const pending: Array<{ resolve: (segments: Float32Array) => void; reject: (error: Error) => void }> = [];
|
||||
const setSegments = vi.spyOn(JumpLinkRenderer.prototype, 'setSegments');
|
||||
const component = linkScene(() => new Promise<Float32Array>((resolve, reject) => pending.push({ resolve, reject })));
|
||||
await settle();
|
||||
component.display.update((display) => ({ ...display, jumpLinks: false }));
|
||||
TestBed.tick();
|
||||
await settle();
|
||||
component.display.update((display) => ({ ...display, jumpLinks: true }));
|
||||
TestBed.tick();
|
||||
await settle();
|
||||
expect(pending).toHaveLength(2);
|
||||
|
||||
pending[0].reject(new Error('Superseded by a newer request'));
|
||||
await flushAsync();
|
||||
const graph = new Float32Array(6);
|
||||
pending[1].resolve(graph);
|
||||
await flushAsync();
|
||||
|
||||
expect(setSegments).toHaveBeenLastCalledWith(graph);
|
||||
setSegments.mockRestore();
|
||||
});
|
||||
|
||||
it('gives a view on the move a new graph at least every quarter second, rather than waiting for it to stop', async () => {
|
||||
const links = vi.fn((_rangePc: number, _drawn: Uint32Array) => Promise.resolve(new Float32Array(0)));
|
||||
const component = linkScene(links);
|
||||
await settle();
|
||||
|
||||
// A new drawn set about every 150 ms for a second, as an orbit makes one each pass.
|
||||
for (let pass = 1; pass <= 7; pass++) {
|
||||
await changeDrawnStars(component, pass * 40);
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
}
|
||||
|
||||
expect(links.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('draws a late graph for the range still asked for, and not one for a range left behind', async () => {
|
||||
const answers: Array<(segments: Float32Array) => void> = [];
|
||||
const setSegments = vi.spyOn(JumpLinkRenderer.prototype, 'setSegments');
|
||||
const component = linkScene(() => new Promise<Float32Array>((resolve) => answers.push(resolve)));
|
||||
await settle();
|
||||
await changeDrawnStars(component, 40);
|
||||
await settle();
|
||||
expect(answers).toHaveLength(2);
|
||||
|
||||
// For stars no longer drawn, but at the range still asked for: newer than what is on screen.
|
||||
const olderSet = new Float32Array(6);
|
||||
answers[0](olderSet);
|
||||
await flushAsync();
|
||||
expect(setSegments).toHaveBeenLastCalledWith(olderSet);
|
||||
|
||||
component.jumpRangePc.set(5);
|
||||
TestBed.tick();
|
||||
await settle();
|
||||
expect(answers).toHaveLength(3);
|
||||
answers[1](new Float32Array(12));
|
||||
await flushAsync();
|
||||
expect(setSegments).toHaveBeenLastCalledWith(olderSet);
|
||||
|
||||
const current = new Float32Array(18);
|
||||
answers[2](current);
|
||||
await flushAsync();
|
||||
expect(setSegments).toHaveBeenLastCalledWith(current);
|
||||
setSegments.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the answer to the latest route asked for, whatever order the answers arrive in', async () => {
|
||||
type Answer = { route: { stars: number[]; totalPc: number; longestHopPc: number } | null; neededRangePc: number | null; gaveUp: boolean; least: boolean };
|
||||
const answers: Array<(answer: Answer) => void> = [];
|
||||
const component = fixture.componentInstance as unknown as {
|
||||
routing: { route(): Promise<Answer>; links(): Promise<Float32Array>; dispose(): void };
|
||||
routePending(): boolean;
|
||||
routeResult(): { stars: { id: number }[]; gaveUp: boolean } | null;
|
||||
onRouteRequested(request: { fromId: number; toId: number; rangePc: number }): void;
|
||||
};
|
||||
component.routing = {
|
||||
route: () => new Promise<Answer>((resolve) => answers.push(resolve)),
|
||||
links: () => Promise.resolve(new Float32Array(0)),
|
||||
dispose: () => undefined
|
||||
};
|
||||
|
||||
component.onRouteRequested({ fromId: SUN.id, toId: ALPHA_CENTAURI.id, rangePc: 2 });
|
||||
component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 });
|
||||
expect(component.routePending()).toBe(true);
|
||||
|
||||
answers[1]({ route: { stars: [SUN.id, PROXIMA.id], totalPc: 1.3, longestHopPc: 1.3 }, neededRangePc: null, gaveUp: false, least: true });
|
||||
await flushAsync();
|
||||
answers[0]({ route: { stars: [SUN.id, ALPHA_CENTAURI.id], totalPc: 1.34, longestHopPc: 1.34 }, neededRangePc: null, gaveUp: false, least: true });
|
||||
await flushAsync();
|
||||
|
||||
expect(component.routeResult()?.stars.map((star) => star.id)).toEqual([SUN.id, PROXIMA.id]);
|
||||
expect(component.routePending()).toBe(false);
|
||||
|
||||
// "It gave up" travels to the panel, which says something else for it than for "there is none".
|
||||
component.onRouteRequested({ fromId: SUN.id, toId: ALPHA_CENTAURI.id, rangePc: 0.5 });
|
||||
answers[2]({ route: null, neededRangePc: null, gaveUp: true, least: false });
|
||||
await flushAsync();
|
||||
expect(component.routeResult()).toMatchObject({ stars: [], gaveUp: true });
|
||||
});
|
||||
|
||||
it('releases the routes panel when a route cannot be worked out, so it can be tried again', async () => {
|
||||
const component = fixture.componentInstance as unknown as {
|
||||
routing: { route(): Promise<never>; links(): Promise<Float32Array>; dispose(): void };
|
||||
routePending(): boolean;
|
||||
onRouteRequested(request: { fromId: number; toId: number; rangePc: number }): void;
|
||||
};
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
component.routing = { route: () => Promise.reject(new Error('worker gone')), links: () => Promise.resolve(new Float32Array(0)), dispose: () => undefined };
|
||||
|
||||
component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 });
|
||||
await flushAsync();
|
||||
|
||||
expect(component.routePending()).toBe(false);
|
||||
expect(logged).toHaveBeenCalled();
|
||||
logged.mockRestore();
|
||||
});
|
||||
|
||||
it('asks for no more label candidates once the last label it will show is placed', () => {
|
||||
// Near the Sun a label candidate past the fifteenth can sit at the far end of the catalogue's
|
||||
// brightness order, so asking for one more than is used can cost a walk of the whole order.
|
||||
const component = fixture.componentInstance as unknown as {
|
||||
spreadLabels(candidates: Iterable<{ id: number; name: string; x: number; y: number; z: number }>, camera: THREE.Camera, keepId: null): unknown[];
|
||||
};
|
||||
const camera = engine.getCamera();
|
||||
camera.updateMatrixWorld(true);
|
||||
camera.updateProjectionMatrix();
|
||||
let pulled = 0;
|
||||
const grid = function* () {
|
||||
for (let row = 0; row < 5; row++) {
|
||||
for (let column = 0; column < 5; column++) {
|
||||
pulled++;
|
||||
const point = new THREE.Vector3(-0.8 + column * 0.4, -0.8 + row * 0.4, 0.5).unproject(camera);
|
||||
yield { id: row * 5 + column, name: `label-${pulled}`, x: point.x, y: point.y, z: point.z };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expect(component.spreadLabels(grid(), camera, null)).toHaveLength(15);
|
||||
expect(pulled).toBe(15);
|
||||
});
|
||||
|
||||
it('flies the camera into a selected star system: hides the galaxy group, shows the system group, and switches to AU-scale near/far planes', async () => {
|
||||
navigationStore.selectStar(SUN.id);
|
||||
await flushAsync();
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,13 +152,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,7 +168,7 @@ 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();
|
||||
});
|
||||
|
||||
@@ -182,7 +182,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 +195,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 +212,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 +260,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 +286,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);
|
||||
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 { BrightnessIndex, brightnessIndex, Positioned } from '../../shared/astro/brightest';
|
||||
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
|
||||
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,11 +52,50 @@ 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;
|
||||
export const FOCUS_RADIUS_PC = 25;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 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 SUN: Positioned = { x: 0, y: 0, z: 0 };
|
||||
|
||||
const COLD_STAR_COLOR = new THREE.Color(0.65, 0.75, 1.0);
|
||||
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
|
||||
@@ -109,7 +141,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 +248,81 @@ 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
|
||||
) {
|
||||
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) => {
|
||||
this.catalogueColors = new Float32Array(catalogue.length * 3);
|
||||
this.catalogueSizes = new Float32Array(catalogue.length);
|
||||
catalogue.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[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 +332,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,16 +407,23 @@ export class StarFieldRenderer {
|
||||
* what the user sees at every zoom level instead of being over-permissive up close and
|
||||
* sub-pixel at the far end of the camera's range.
|
||||
*/
|
||||
pickAt(pointerNdc: THREE.Vector2, camera: THREE.PerspectiveCamera): number | undefined {
|
||||
const tanHalfFov = Math.tan((camera.fov * Math.PI) / 360);
|
||||
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) {
|
||||
@@ -257,8 +432,8 @@ export class StarFieldRenderer {
|
||||
|
||||
// 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 ndcRadius = (0.5 * sizes[index]) / tanHalfFov + 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 +443,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();
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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,23 @@ 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>
|
||||
</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 +81,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()));
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ function colorForKind(kind: SystemMemberKind): THREE.Color {
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks orbit lines so the whole layer can be toggled without touching the bodies. */
|
||||
const ORBIT_LINE_NAME = 'orbit-line';
|
||||
|
||||
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame: THREE.Quaternion): THREE.Line {
|
||||
const points = orbitEllipsePoints(elements);
|
||||
const positions = new Float32Array(points.length * 3);
|
||||
@@ -110,7 +113,9 @@ 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;
|
||||
return line;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -329,6 +334,21 @@ 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 {
|
||||
this.grid?.dispose();
|
||||
this.tethers?.dispose();
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||
import { BookmarksStore } from '../../shared/state/bookmarks.store';
|
||||
import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent } from './hud-dock.component';
|
||||
|
||||
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('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']);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, ElementRef, HostListener, inject, input, OnInit, output, signal, viewChild } from '@angular/core';
|
||||
|
||||
import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.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">
|
||||
<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>
|
||||
</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">
|
||||
<div role="tablist" aria-label="Dock" class="flex items-stretch divide-x divide-border/40">
|
||||
@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>
|
||||
@if (range()) {
|
||||
<p class="ml-auto flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4">
|
||||
<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('');
|
||||
/** Layer state; `null` means the surface has no layers to toggle and no Display tab. */
|
||||
readonly display = input<HudDisplay | null>(null);
|
||||
/** 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() ? (['display'] as const) : [])
|
||||
]);
|
||||
|
||||
readonly activeTab = signal<DockTab | null>(null);
|
||||
|
||||
readonly bookmarks = inject(BookmarksStore);
|
||||
|
||||
private readonly search = viewChild(SearchComponent);
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.activeTab.set(isWideViewport() ? this.defaultTab() : null);
|
||||
}
|
||||
|
||||
tabLabel(tab: DockTab): string {
|
||||
return 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);
|
||||
}
|
||||
|
||||
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() });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
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';
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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> {
|
||||
|
||||
@@ -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]];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
OBLIQUITY_J2000_DEG,
|
||||
parallaxMasToParsecs,
|
||||
parseSexagesimal,
|
||||
propagateProperMotion,
|
||||
raDecDistanceToXyz,
|
||||
raDecToUnitVector,
|
||||
raDegDecDistanceToXyz
|
||||
@@ -59,6 +60,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildStarNameIndex, 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,150 @@ 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 J2016, 46″ 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 sixteen 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, 16);
|
||||
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 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,25 +179,25 @@ 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('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);
|
||||
});
|
||||
|
||||
@@ -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,92 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`
|
||||
* 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's J2016
|
||||
* (the carried-back position lands to 0.1″). So every query is tried at both ends — as
|
||||
* published, and carried back sixteen years with the archive's own proper motion — and a star
|
||||
* is judged on whichever is closer. Guessing one epoch picks companions: assume J2016 and
|
||||
* Aldebaran's planet lands on Gl 171.1B, assume J2000 and GJ 15 A's land on a Gaia entry
|
||||
* 15.9″ out.
|
||||
*/
|
||||
const CATALOGUE_EPOCH = 2000.0;
|
||||
const ARCHIVE_LATEST_EPOCH = 2016.0;
|
||||
|
||||
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 +110,49 @@ 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;
|
||||
|
||||
for (const star of stars) {
|
||||
const distance = distanceBetween(position, star);
|
||||
if (distance <= toleranceInPc && (!closest || distance < closest.distance)) {
|
||||
closest = { id: star.id, distance };
|
||||
}
|
||||
}
|
||||
|
||||
return closest ? closest.id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-resolves every exoplanet's host star against a star catalogue.
|
||||
*
|
||||
* The cross-reference is a *derived* fact: it depends as much on which stars were loaded as on
|
||||
* the archive itself. When the catalogue reached 50 pc, 388 of the archive's 4735 named hosts
|
||||
* found a match and the other 4347 were carried and never drawn — not because their planets are
|
||||
* unknown, but because their star was out of range. Widening the catalogue rescues some of them,
|
||||
* and until the host coordinates were stored alongside each planet that meant re-downloading an
|
||||
* archive which is not always reachable.
|
||||
*
|
||||
* Records written before those coordinates were kept can still be matched *by name*, which needs
|
||||
* no coordinates at all — and that alone is worth doing, because a wider catalogue contains more
|
||||
* names. What such a record cannot do is disprove its existing match: a name miss means only
|
||||
* that the name missed, not that the star is absent. So those are upgraded where a match is
|
||||
* found and left alone otherwise, while records that do carry coordinates take the new result
|
||||
* outright, match or no match.
|
||||
*/
|
||||
|
||||
/** A host must sit within this many parsecs of a catalogue star to count as the same object. */
|
||||
export const HOST_MATCH_TOLERANCE_PC = 2;
|
||||
|
||||
export interface RematchSummary {
|
||||
total: number;
|
||||
/** Records carrying host coordinates, and therefore eligible to be re-matched in full. */
|
||||
resolvable: number;
|
||||
matched: number;
|
||||
gained: number;
|
||||
lost: number;
|
||||
}
|
||||
|
||||
export function rematchHostStars(exoplanets: ExoplanetRecord[], stars: readonly StarRecord[]): RematchSummary {
|
||||
const nameIndex = buildStarNameIndex(stars);
|
||||
const summary: RematchSummary = { total: exoplanets.length, resolvable: 0, matched: 0, gained: 0, lost: 0 };
|
||||
|
||||
for (const exoplanet of exoplanets) {
|
||||
const { hostRaDeg, hostDecDeg, hostDistancePc } = exoplanet;
|
||||
const positioned = hostRaDeg !== undefined && hostDecDeg !== undefined && hostDistancePc !== undefined;
|
||||
if (positioned) {
|
||||
summary.resolvable++;
|
||||
}
|
||||
|
||||
const previous = exoplanet.hostStarId;
|
||||
// With no coordinates the query still carries the host's name, and `resolveHostStarId` tries
|
||||
// that first; the positional fallback simply declines to run on non-finite coordinates.
|
||||
const resolved = resolveHostStarId(
|
||||
{ hostname: exoplanet.hostStarName, raDeg: hostRaDeg ?? Number.NaN, decDeg: hostDecDeg ?? Number.NaN, distancePc: hostDistancePc ?? Number.NaN },
|
||||
stars,
|
||||
HOST_MATCH_TOLERANCE_PC,
|
||||
nameIndex
|
||||
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_LATEST_EPOCH
|
||||
);
|
||||
const carried = raDegDecDistanceToXyz(carriedBack.raDeg, carriedBack.decDeg, 1);
|
||||
|
||||
exoplanet.hostStarId = positioned ? resolved : (resolved ?? previous);
|
||||
if (exoplanet.hostStarId !== null) {
|
||||
summary.matched++;
|
||||
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 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;
|
||||
}
|
||||
if (previous === null && exoplanet.hostStarId !== null) {
|
||||
summary.gained++;
|
||||
} else if (previous !== null && exoplanet.hostStarId === null) {
|
||||
summary.lost++;
|
||||
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;
|
||||
}
|
||||
const [near, far] = query.distancePc < starDistance ? [query.distancePc, starDistance] : [starDistance, query.distancePc];
|
||||
if ((far - near) / near > MERGE_DISTANCE_RATIO_TOLERANCE) {
|
||||
continue;
|
||||
}
|
||||
best = star;
|
||||
bestCosine = cosine;
|
||||
}
|
||||
|
||||
return summary;
|
||||
return best ? best.id : null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
});
|
||||
@@ -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, isSameStar, MERGE_ANGULAR_TOLERANCE_DEG, mergeStarCatalogues, 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,70 @@ 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);
|
||||
});
|
||||
|
||||
it('matches on direction rather than on 3D proximity', () => {
|
||||
@@ -72,6 +118,44 @@ 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 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 +207,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 +237,35 @@ describe('mergeStarCatalogues', () => {
|
||||
expect(Date.now() - started).toBeLessThan(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placementDistancePc', () => {
|
||||
it("draws a star both surveys measured at Gaia's distance", () => {
|
||||
expect(placementDistancePc(120, 118.4, 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, 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, 250)).toBe(241);
|
||||
});
|
||||
|
||||
it('keeps a star Gaia measured and Hipparcos gave no distance for', () => {
|
||||
expect(placementDistancePc(undefined, 180, 250)).toBe(180);
|
||||
});
|
||||
|
||||
it('falls back to Hipparcos where Gaia has no usable distance', () => {
|
||||
expect(placementDistancePc(90, undefined, 250)).toBe(90);
|
||||
});
|
||||
|
||||
it('drops a star both surveys put outside, or neither measured', () => {
|
||||
expect(placementDistancePc(300, 410, 250)).toBeNull();
|
||||
expect(placementDistancePc(300, undefined, 250)).toBeNull();
|
||||
expect(placementDistancePc(undefined, undefined, 250)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isDesignation } from '../models/star-catalog';
|
||||
import { StarRecord } from '../models/star.model';
|
||||
|
||||
/**
|
||||
@@ -17,8 +18,47 @@ 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;
|
||||
|
||||
/**
|
||||
* 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 +67,28 @@ export const MERGE_ANGULAR_TOLERANCE_DEG = 1 / 3600;
|
||||
*/
|
||||
export const MERGE_DISTANCE_RATIO_TOLERANCE = 0.5;
|
||||
|
||||
/**
|
||||
* Where to draw a star Hipparcos and Gaia both measured, and whether the map keeps it at all.
|
||||
*
|
||||
* Gaia's distance wherever it has a usable one, since its parallaxes are fifty times more
|
||||
* precise; Hipparcos's otherwise. 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. `null` for a star neither survey places
|
||||
* inside, or that no survey gives a distance for.
|
||||
*/
|
||||
export function placementDistancePc(hipparcosPc: number | undefined, gaiaPc: number | undefined, cutoffPc: number): number | null {
|
||||
const best = gaiaPc ?? hipparcosPc;
|
||||
if (best === undefined) {
|
||||
return null;
|
||||
}
|
||||
const inside = 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 +98,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 +122,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 +153,13 @@ 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 `entry` describes the star already `kept`: the same direction, the brightness not in
|
||||
* conflict and — unless the directions agree closely enough to settle it — the distance not in
|
||||
* conflict 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 +168,53 @@ export function isSameStar(a: StarRecord, b: StarRecord): boolean {
|
||||
return far === 0;
|
||||
}
|
||||
|
||||
const separationDeg = Math.acos(directionCosine(a, b)) / DEG_TO_RAD;
|
||||
const separationDeg = Math.acos(directionCosine(kept, entry)) / DEG_TO_RAD;
|
||||
if (separationDeg > MERGE_ANGULAR_TOLERANCE_DEG) {
|
||||
return false;
|
||||
}
|
||||
const fainterBy = entry.magnitude - kept.magnitude;
|
||||
if (fainterBy < -MERGE_BRIGHTER_TOLERANCE || fainterBy > MERGE_FAINTER_TOLERANCE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (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;
|
||||
* `source` stays with the position, since that is what it records.
|
||||
*/
|
||||
function combine(kept: StarRecord, other: StarRecord): StarRecord {
|
||||
const described = isDesignation(kept) && !isDesignation(other) ? other : kept;
|
||||
return { ...described, x: kept.x, y: kept.y, z: kept.z, source: kept.source };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +222,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)) ?? [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -21,17 +21,19 @@ export interface ExoplanetRecord {
|
||||
/** Host star mass in solar masses (`st_mass`); the fallback when no period is published. */
|
||||
hostStarMassSolar?: number;
|
||||
/**
|
||||
* The host star's own published position (`ra`, `dec`, `sy_dist`) — the coordinates the
|
||||
* cross-reference above is resolved from.
|
||||
* The host star's own published astrometry (`ra`, `dec`, `sy_dist`, `sy_pmra`, `sy_pmdec`) —
|
||||
* everything the cross-reference above was resolved from.
|
||||
*
|
||||
* Kept rather than consumed and discarded. `hostStarId` is the *result* of a match against
|
||||
* whatever star catalogue was loaded at the time, so widening that catalogue ought to rescue
|
||||
* some of the 4347 hosts that currently resolve to nothing — but with only the result stored,
|
||||
* redoing the match meant re-downloading the archive. These three numbers make it a local
|
||||
* operation. See `rematchHostStars`.
|
||||
* whatever star catalogue was loaded at the time; keeping the inputs makes auditing or
|
||||
* redoing that match a local operation instead of a TAP query against an archive that is not
|
||||
* always reachable — it is how the matcher's tolerances were measured. See
|
||||
* `resolveHostStarId`.
|
||||
*/
|
||||
hostRaDeg?: number;
|
||||
hostDecDeg?: number;
|
||||
hostDistancePc?: number;
|
||||
hostPmRaMasPerYear?: number;
|
||||
hostPmDecMasPerYear?: number;
|
||||
orbit: Partial<OrbitalElements>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog } from './star-catalog';
|
||||
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog, isDesignation } from './star-catalog';
|
||||
import { StarRecord } from './star.model';
|
||||
|
||||
const STARS: StarRecord[] = [
|
||||
@@ -131,4 +131,18 @@ describe('star catalogue provenance and derived names', () => {
|
||||
expect(named.index.names).toEqual(['Some Proper Name']);
|
||||
expect(decodeStarCatalog(named.index, named.positions, named.meta)[0].name).toBe('Some Proper Name');
|
||||
});
|
||||
|
||||
it('tells a name somebody gave from the designation a survey generates', () => {
|
||||
expect(isDesignation(decoded[0])).toBe(false);
|
||||
expect(isDesignation(decoded[1])).toBe(true);
|
||||
// The real catalogue names Gaia stars by the survey's nineteen-digit source id, which no
|
||||
// 32-bit row id can equal — so it is the prefix that decides, not a round trip through the id.
|
||||
expect(isDesignation({ ...MIXED[1], name: 'Gaia DR3 5853498713190525696' })).toBe(true);
|
||||
expect(isDesignation({ ...MIXED[1], name: 'Proxima Centauri' })).toBe(false);
|
||||
// HYG's own last resort, once every designation it knows has come up empty. Upper case,
|
||||
// whatever case the source id is in.
|
||||
expect(isDesignation({ ...MIXED[0], name: 'HYG 5' })).toBe(true);
|
||||
expect(isDesignation({ ...MIXED[0], source: undefined, name: 'HYG 5' })).toBe(true);
|
||||
expect(isDesignation({ ...MIXED[0], name: 'HD 48915' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,11 +66,14 @@ export interface StarCatalogIndex {
|
||||
}
|
||||
|
||||
/**
|
||||
* How a source names a star that has no name of its own. `Gaia DR3 <id>` for Gaia; HYG's own
|
||||
* fallbacks already produce real designations, so it never needs this.
|
||||
* How a source names a star that has no name of its own. `Gaia DR3 <id>` for Gaia; `HYG <id>`
|
||||
* for HYG, whose ETL reaches for that only after a proper name, Bayer, Flamsteed, HD, Gliese and
|
||||
* HIP have all come up empty (`tools/etl/fetchStars.ts`) — none in the current catalogue, but
|
||||
* the path is there, and a name made that way is no more a name than Gaia's.
|
||||
*/
|
||||
const DESIGNATION_PREFIXES: Readonly<Record<string, string>> = {
|
||||
gaia: 'Gaia DR3'
|
||||
gaia: 'Gaia DR3',
|
||||
hyg: 'HYG'
|
||||
};
|
||||
|
||||
interface StarMetaColumns {
|
||||
@@ -161,6 +164,18 @@ function designationFor(prefix: string | undefined, id: number): string | undefi
|
||||
return prefix === undefined ? undefined : `${prefix} ${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a star's name is only the designation its source generates, rather than anything
|
||||
* somebody called it. Judged by the prefix alone: the number after it is the survey's own id —
|
||||
* nineteen digits for Gaia — which the 32-bit row id `designationFor` prints cannot hold, so a
|
||||
* round-trip through the id would call every one of those stars named.
|
||||
*/
|
||||
export function isDesignation(star: StarRecord): boolean {
|
||||
// No source at all is a single-catalogue build, whose fallback is HYG's — see `decodeStarCatalog`.
|
||||
const prefix = star.source === undefined ? DESIGNATION_PREFIXES['hyg'] : (DESIGNATION_PREFIXES[star.source] ?? star.source);
|
||||
return star.name.startsWith(`${prefix} `);
|
||||
}
|
||||
|
||||
/** Rebuilds the star records the app works with from the three loaded assets. */
|
||||
export function decodeStarCatalog(index: StarCatalogIndex, positions: Float32Array, meta: ArrayBuffer): StarRecord[] {
|
||||
const columns = metaColumns(meta, index.count);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Bookmark, BookmarksStore } from './bookmarks.store';
|
||||
|
||||
const KEY = 'star-map.bookmarks';
|
||||
|
||||
const SIRIUS: Bookmark = { kind: 'star', id: 32349, name: 'Sirius' };
|
||||
const EARTH: Bookmark = { kind: 'body', id: 'earth', name: 'Earth' };
|
||||
|
||||
function store(): BookmarksStore {
|
||||
// Constructed per test, because the list is read once on construction — which is the
|
||||
// behaviour being tested for anything that seeds storage first.
|
||||
TestBed.resetTestingModule();
|
||||
return TestBed.inject(BookmarksStore);
|
||||
}
|
||||
|
||||
describe('BookmarksStore', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('starts empty, and keeps what it is given', () => {
|
||||
const bookmarks = store();
|
||||
expect(bookmarks.bookmarks()).toEqual([]);
|
||||
|
||||
expect(bookmarks.toggle(SIRIUS)).toBe(true);
|
||||
expect(bookmarks.bookmarks()).toEqual([SIRIUS]);
|
||||
expect(bookmarks.has('star', 32349)).toBe(true);
|
||||
});
|
||||
|
||||
it('drops what it already had, when told the same thing twice', () => {
|
||||
const bookmarks = store();
|
||||
bookmarks.toggle(SIRIUS);
|
||||
|
||||
expect(bookmarks.toggle(SIRIUS)).toBe(false);
|
||||
expect(bookmarks.bookmarks()).toEqual([]);
|
||||
expect(bookmarks.has('star', 32349)).toBe(false);
|
||||
});
|
||||
|
||||
it('tells a star from a body that happen to share an id', () => {
|
||||
const bookmarks = store();
|
||||
bookmarks.toggle({ kind: 'star', id: 1, name: 'A star' });
|
||||
bookmarks.toggle({ kind: 'body', id: 1, name: 'A body' });
|
||||
|
||||
expect(bookmarks.bookmarks()).toHaveLength(2);
|
||||
bookmarks.remove('star', 1);
|
||||
expect(bookmarks.bookmarks()).toEqual([{ kind: 'body', id: 1, name: 'A body' }]);
|
||||
});
|
||||
|
||||
it('puts the newest first, since that is the one being come back to', () => {
|
||||
const bookmarks = store();
|
||||
bookmarks.toggle(SIRIUS);
|
||||
bookmarks.toggle(EARTH);
|
||||
|
||||
expect(bookmarks.bookmarks().map((bookmark) => bookmark.name)).toEqual(['Earth', 'Sirius']);
|
||||
});
|
||||
|
||||
it('survives the visit it was kept in', () => {
|
||||
store().toggle(SIRIUS);
|
||||
|
||||
expect(store().bookmarks()).toEqual([SIRIUS]);
|
||||
});
|
||||
|
||||
it('reads past whatever else is in there, rather than losing the lot', () => {
|
||||
localStorage.setItem(KEY, JSON.stringify([SIRIUS, { kind: 'moon', id: 1, name: 'No such kind' }, { id: 'no-kind' }, null, 42, EARTH]));
|
||||
|
||||
expect(store().bookmarks()).toEqual([SIRIUS, EARTH]);
|
||||
});
|
||||
|
||||
it('keeps one entry per place, however many the stored list holds', () => {
|
||||
// Two entries for one place would each toggle the other's control on and off.
|
||||
localStorage.setItem(KEY, JSON.stringify([SIRIUS, { ...SIRIUS, name: 'Sirius (again)' }]));
|
||||
|
||||
expect(store().bookmarks()).toEqual([SIRIUS]);
|
||||
});
|
||||
|
||||
it('treats a store that is not a list, or not JSON at all, as no bookmarks', () => {
|
||||
localStorage.setItem(KEY, '{"not":"a list"}');
|
||||
expect(store().bookmarks()).toEqual([]);
|
||||
|
||||
localStorage.setItem(KEY, 'nonsense{');
|
||||
expect(store().bookmarks()).toEqual([]);
|
||||
});
|
||||
|
||||
it('goes on working where the browser will not store anything at all', () => {
|
||||
// Private mode, a full quota, storage disabled by policy: reading throws, writing throws.
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new DOMException('denied');
|
||||
});
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('denied');
|
||||
});
|
||||
|
||||
const bookmarks = store();
|
||||
expect(bookmarks.bookmarks()).toEqual([]);
|
||||
expect(() => bookmarks.toggle(SIRIUS)).not.toThrow();
|
||||
// Kept for this visit, even though nothing will outlive it.
|
||||
expect(bookmarks.bookmarks()).toEqual([SIRIUS]);
|
||||
});
|
||||
|
||||
it('bounds what a hand-edited store can make it hold', () => {
|
||||
const many = Array.from({ length: 500 }, (_, i) => ({ kind: 'star' as const, id: i, name: `Star ${i}` }));
|
||||
localStorage.setItem(KEY, JSON.stringify(many));
|
||||
|
||||
expect(store().bookmarks()).toHaveLength(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* A place someone chose to keep. Either a star, which is a system to fly into, or a body, which
|
||||
* is a page to open — the two things the map lets you arrive at.
|
||||
*/
|
||||
export interface Bookmark {
|
||||
readonly kind: 'star' | 'body';
|
||||
/** A HYG star id, or a `bodies.json`/`exoplanets.json` id. */
|
||||
readonly id: number | string;
|
||||
/**
|
||||
* The name as it read when it was kept. Stored rather than looked up, so the list can be
|
||||
* shown before the catalogues have loaded — and so a bookmark to something a later catalogue
|
||||
* no longer holds still says what it was rather than becoming a bare id.
|
||||
*/
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'star-map.bookmarks';
|
||||
|
||||
/**
|
||||
* How many are kept. Not a limit anyone will reach by hand — it is a bound on what a corrupted
|
||||
* or hand-edited store can make the app render, and on what is written back.
|
||||
*/
|
||||
const MAX_BOOKMARKS = 200;
|
||||
|
||||
/** `${kind}:${id}`, since a star id and a body id are different kinds of thing. */
|
||||
function keyOf(kind: Bookmark['kind'], id: number | string): string {
|
||||
return `${kind}:${id}`;
|
||||
}
|
||||
|
||||
function isBookmark(value: unknown): value is Bookmark {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as Partial<Bookmark>;
|
||||
return (
|
||||
(candidate.kind === 'star' || candidate.kind === 'body') &&
|
||||
(typeof candidate.id === 'number' || typeof candidate.id === 'string') &&
|
||||
typeof candidate.name === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The places kept between visits, in this browser and nowhere else.
|
||||
*
|
||||
* Local storage rather than an account: the map asks nobody to sign in, and a list of stars
|
||||
* somebody liked is not worth a server. Every read of it is defensive — the store is a string
|
||||
* a user can edit, another tab can write, and a browser can refuse to give at all — and a
|
||||
* failure to read or write one is never allowed to take the map down with it.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BookmarksStore {
|
||||
private readonly kept = signal<readonly Bookmark[]>(this.read());
|
||||
|
||||
/** Most recently kept first, which is the order they are useful in. */
|
||||
readonly bookmarks = this.kept.asReadonly();
|
||||
|
||||
has(kind: Bookmark['kind'], id: number | string): boolean {
|
||||
return this.kept().some((bookmark) => bookmark.kind === kind && bookmark.id === id);
|
||||
}
|
||||
|
||||
/** Keeps a place, or drops it if it was already kept. Returns whether it is kept now. */
|
||||
toggle(bookmark: Bookmark): boolean {
|
||||
const kept = this.has(bookmark.kind, bookmark.id);
|
||||
this.write(kept ? this.kept().filter((other) => !(other.kind === bookmark.kind && other.id === bookmark.id)) : [bookmark, ...this.kept()].slice(0, MAX_BOOKMARKS));
|
||||
return !kept;
|
||||
}
|
||||
|
||||
remove(kind: Bookmark['kind'], id: number | string): void {
|
||||
this.write(this.kept().filter((bookmark) => !(bookmark.kind === kind && bookmark.id === id)));
|
||||
}
|
||||
|
||||
private write(bookmarks: readonly Bookmark[]): void {
|
||||
this.kept.set(bookmarks);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(bookmarks));
|
||||
} catch {
|
||||
// Full, disabled, or private-mode storage. The list still works for this visit; it just
|
||||
// will not outlive it, which is a smaller loss than the alternative of failing here.
|
||||
}
|
||||
}
|
||||
|
||||
private read(): Bookmark[] {
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
raw = localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
// Filtered rather than rejected wholesale: one bad entry should not lose the others, and
|
||||
// deduplicated because two entries for one place would each toggle the other's control.
|
||||
const seen = new Set<string>();
|
||||
return parsed
|
||||
.filter(isBookmark)
|
||||
.filter((bookmark) => !seen.has(keyOf(bookmark.kind, bookmark.id)) && seen.add(keyOf(bookmark.kind, bookmark.id)))
|
||||
.slice(0, MAX_BOOKMARKS)
|
||||
.map(({ kind, id, name }) => ({ kind, id, name }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
|
||||
/**
|
||||
* The mark on a place worth coming back to. Hollow when it is not kept, filled when it is —
|
||||
* the same two states as the Display panel's layer ticks, which is where the eye has already
|
||||
* learned what a filled mark means here.
|
||||
*
|
||||
* Size and colour come from the classes on the host; the svg fills it.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-bookmark-icon',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'block', 'aria-hidden': 'true' },
|
||||
template: `
|
||||
<svg class="h-full w-full" viewBox="0 0 24 24" [attr.fill]="kept() ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
|
||||
<path d="M7 4h10v16l-5-4-5 4z" />
|
||||
</svg>
|
||||
`
|
||||
})
|
||||
export class BookmarkIconComponent {
|
||||
readonly kept = input(false);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+78
-1
@@ -87,8 +87,13 @@ body {
|
||||
|
||||
/* The one authored moment: a panel doesn't fade in, it acquires — wiping down under an
|
||||
* exponential ease-out the way a readout fills once an instrument locks onto a target. */
|
||||
/* `backwards`, not `both`: `both` leaves the final keyframe applied for good, and that keyframe
|
||||
* carries a clip-path — which goes on clipping the panel's own box forever after, so anything
|
||||
* that has to escape it (a dropdown opening upward, a tooltip) is cut away and cannot even be
|
||||
* clicked. `backwards` holds the opening frame before the wipe and lets the element return to
|
||||
* its own styles after it. */
|
||||
@utility hud-acquire {
|
||||
animation: hud-acquire 380ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation: hud-acquire 380ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes hud-acquire {
|
||||
@@ -154,6 +159,64 @@ body {
|
||||
background: color-mix(in oklab, var(--color-accent) 60%, transparent);
|
||||
}
|
||||
|
||||
/* The quieter voice: a star outside the system the camera is in, named so its direction can be
|
||||
* read without leaving. Dimmed rather than shrunk — at the same size it still reads as a name,
|
||||
* where a smaller one would read as a footnote to the system rather than a place beyond it. */
|
||||
.map-label--ghost .map-label-name {
|
||||
/* 65% of the accent over the void is 5.4:1 — a ghost is interactive, so it holds the same
|
||||
* contrast floor as body text rather than the lower one large or decorative text could take. */
|
||||
color: color-mix(in oklab, var(--color-accent) 65%, transparent);
|
||||
}
|
||||
|
||||
.map-label--ghost .map-label-kind {
|
||||
/* Quieter than the name it belongs to, and still 4.7:1. */
|
||||
color: color-mix(in oklab, var(--color-accent) 60%, transparent);
|
||||
}
|
||||
|
||||
.map-label--ghost::before {
|
||||
background: color-mix(in oklab, var(--color-accent) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Selectable labels are buttons: the only ones the pointer can reach, since the layer they
|
||||
* live in is transparent to it. They brighten to full strength on approach, which is the whole
|
||||
* hover affordance — a ghost lighting up is the map saying this one is somewhere to go. */
|
||||
.map-label--select {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.map-label--select:hover .map-label-name,
|
||||
.map-label--select:focus-visible .map-label-name {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.map-label--select:hover::before,
|
||||
.map-label--select:focus-visible::before {
|
||||
background: color-mix(in oklab, var(--color-accent) 60%, transparent);
|
||||
}
|
||||
|
||||
.map-label--select:focus-visible {
|
||||
outline: 1px solid var(--color-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* The mirror: text hangs off the point's left, right-aligned, leader line on its right. */
|
||||
.map-label--left {
|
||||
margin-left: 0;
|
||||
margin-right: 0.875rem;
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.map-label--left::before {
|
||||
left: auto;
|
||||
right: -0.75rem;
|
||||
}
|
||||
|
||||
.map-label-name {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
@@ -167,3 +230,17 @@ body {
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in oklab, var(--color-accent) 70%, transparent);
|
||||
}
|
||||
|
||||
/* The selection mark on a body in the scene: two hairline arcs bracketing the point — the one
|
||||
* mark borrowed from the ARK's control disc, at hairline weight. Sized in pixels because it sits
|
||||
* on the body's projected position and should read the same however far the camera is. Centred
|
||||
* on the point by CSS2DRenderer's default (0.5, 0.5) anchor. */
|
||||
.map-select {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid transparent;
|
||||
border-left-color: var(--color-accent);
|
||||
border-right-color: var(--color-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
+82
-13
@@ -10,7 +10,6 @@ import { fetchSolarSystem } from './fetchSolarSystem';
|
||||
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
|
||||
import { fetchStars } from './fetchStars';
|
||||
import { describeSources } from './sources/registry';
|
||||
import { rematchHostStars } from '../../src/app/shared/astro/host-star-matching';
|
||||
import { dataPath } from './lib/paths';
|
||||
|
||||
class ValidationError extends Error {}
|
||||
@@ -50,6 +49,87 @@ function validateStars(stars: StarRecord[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a good merge looks like, in two numbers the unit suite cannot see.
|
||||
*
|
||||
* The catalogues are regenerated by a scheduled job that pushes straight to `main` once the unit
|
||||
* tests and a production build pass — and both passed, for weeks, on a catalogue carrying 23 000
|
||||
* stars twice: the suite tests code against fixtures, and no fixture is 400 000 real stars. The
|
||||
* two ways the merge has actually failed both show up here.
|
||||
*
|
||||
* A star kept twice leaves its two entries near each other on the sky, from *different* sources —
|
||||
* one catalogue does not list a star twice. Under an arcsecond that is never two stars at this
|
||||
* depth, so every such pair is a miss. Nineteen survive today, all of them a second HYG row
|
||||
* wanting a Gaia entry that already absorbed one (Gliese lists some doubles twice); the merge
|
||||
* that trusted a Hipparcos parallax over direction left 1 112.
|
||||
*
|
||||
* The other failure leaves no close pair at all, because proper motion had already carried the
|
||||
* two entries tens of arcseconds apart — the 2026-08-24 refresh, where HYG sat at epoch 2000.0
|
||||
* and Gaia at J2016.0. What it does leave is HYG rows that found no counterpart: 36 056 of them
|
||||
* against the 10 886 today, and no counterpart was possible for most of those. Two thirds of them,
|
||||
* 6 835, are the stars Gaia measures but the main query never downloads, because Gaia's parallax
|
||||
* puts them past `ETL_GAIA_DISTANCE_PC` while Hipparcos put them inside `ETL_STAR_DISTANCE_PC`;
|
||||
* they are every star in the published catalogue beyond 250 pc. The rest are what Gaia genuinely
|
||||
* lacks: bright stars it saturates on, red dwarfs past its magnitude cut. So the headroom left to
|
||||
* the ceiling tracks the gap between those two cutoffs as much as Gaia's completeness.
|
||||
*
|
||||
* This bounds a merge that went wrong, and — loosely — a Gaia download that came back short: a
|
||||
* truncated answer leaves the HYG rows whose counterpart it dropped without one, so survivors go
|
||||
* *up*, not down. Measured against the published catalogue: 10 886 today, 11 004 at nine tenths of
|
||||
* the rows, 12 711 at half, 16 258 at a third. So this ceiling only catches a truncation past about
|
||||
* two thirds, and `fetchGaiaStars` catches the shallower ones with its own row floor.
|
||||
*/
|
||||
const MAX_UNMERGED_TWINS = 100;
|
||||
const MAX_HYG_SURVIVORS = 15_000;
|
||||
const TWIN_TOLERANCE_RAD = (1 / 3600) * (Math.PI / 180);
|
||||
|
||||
function validateMerge(stars: StarRecord[]): void {
|
||||
// Checked first and on its own: an unreachable Gaia is skipped rather than thrown, and would
|
||||
// otherwise surface below as "68 000 HYG stars found no counterpart" — true, and no help.
|
||||
assertCondition(
|
||||
stars.some((star) => star.source === 'gaia'),
|
||||
'Gaia DR3 contributed no stars — the archive was unreachable or returned nothing, and a catalogue without it is not one to publish.'
|
||||
);
|
||||
const survivors = stars.filter((star) => star.source === 'hyg').length;
|
||||
assertCondition(
|
||||
survivors <= MAX_HYG_SURVIVORS,
|
||||
`${survivors} HYG stars found no Gaia counterpart (at most ${MAX_HYG_SURVIVORS} expected) — the two catalogues are not being matched.`
|
||||
);
|
||||
|
||||
// Sorted by declination, so each star is only compared against the handful sharing its
|
||||
// parallel — an arcsecond of declination holds one or two of 400 000 stars.
|
||||
const byDec = stars
|
||||
.map((star) => {
|
||||
const distance = Math.hypot(star.x, star.y, star.z);
|
||||
return { star, distance, dec: distance === 0 ? 0 : Math.asin(Math.max(-1, Math.min(1, star.z / distance))) };
|
||||
})
|
||||
.filter((entry) => entry.distance > 0)
|
||||
.sort((a, b) => a.dec - b.dec);
|
||||
|
||||
const cosTolerance = Math.cos(TWIN_TOLERANCE_RAD);
|
||||
let twins = 0;
|
||||
let example = '';
|
||||
for (let i = 0; i < byDec.length; i++) {
|
||||
const a = byDec[i];
|
||||
for (let j = i + 1; j < byDec.length && byDec[j].dec - a.dec <= TWIN_TOLERANCE_RAD; j++) {
|
||||
const b = byDec[j];
|
||||
if (a.star.source === b.star.source) {
|
||||
continue;
|
||||
}
|
||||
const cosine = (a.star.x * b.star.x + a.star.y * b.star.y + a.star.z * b.star.z) / (a.distance * b.distance);
|
||||
if (cosine >= cosTolerance) {
|
||||
twins++;
|
||||
example ||= `${a.star.name} (${a.star.source}) and ${b.star.name} (${b.star.source})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertCondition(
|
||||
twins <= MAX_UNMERGED_TWINS,
|
||||
`${twins} stars from different catalogues sit within an arcsecond of each other (at most ${MAX_UNMERGED_TWINS} expected), starting with ${example} — the merge is keeping the same star twice.`
|
||||
);
|
||||
console.log(` ${survivors} HYG stars have no Gaia counterpart; ${twins} unmerged cross-catalogue pairs within an arcsecond.`);
|
||||
}
|
||||
|
||||
function validateBodies(bodies: BodyRecord[]): void {
|
||||
assertCondition(bodies.length > 0, 'No solar-system bodies were produced.');
|
||||
|
||||
@@ -161,20 +241,9 @@ async function build(): Promise<void> {
|
||||
const deepSky = await fetchDeepSky();
|
||||
console.log();
|
||||
|
||||
// The cross-reference depends on the star catalogue as much as on the archive, so it is
|
||||
// resolved again here against whatever catalogue this run produced. A no-op when the two were
|
||||
// fetched together, and the whole point when only one of them was.
|
||||
const rematch = rematchHostStars(exoplanets, stars);
|
||||
console.log(
|
||||
`Cross-referencing exoplanet hosts against ${stars.length} stars...\n` +
|
||||
` ${rematch.matched}/${rematch.total} matched` +
|
||||
(rematch.resolvable < rematch.total ? ` (${rematch.total - rematch.resolvable} records predate stored host coordinates and kept their existing match)` : '') +
|
||||
(rematch.gained || rematch.lost ? `; ${rematch.gained} gained, ${rematch.lost} lost` : '')
|
||||
);
|
||||
console.log();
|
||||
|
||||
console.log('Validating output...');
|
||||
validateStars(stars);
|
||||
validateMerge(stars);
|
||||
validateBodies(bodies);
|
||||
validateExoplanets(exoplanets, new Set(stars.map((star) => star.id)));
|
||||
validateDeepSky(deepSky);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
import { buildStarNameIndex, resolveHostStarId } from '../../src/app/shared/astro/host-star-matching';
|
||||
@@ -15,6 +16,8 @@ const TAP_COLUMNS = [
|
||||
'ra',
|
||||
'dec',
|
||||
'sy_dist',
|
||||
'sy_pmra',
|
||||
'sy_pmdec',
|
||||
'pl_orbsmax',
|
||||
'pl_orbeccen',
|
||||
'pl_orbincl',
|
||||
@@ -32,9 +35,11 @@ const TAP_COLUMNS = [
|
||||
const TAP_QUERY = `select+${TAP_COLUMNS}+from+ps+where+default_flag=1+order+by+pl_name&format=csv`;
|
||||
const TAP_URL = `${TAP_BASE_URL}?query=${TAP_QUERY}`;
|
||||
|
||||
// A host star match must be within this many parsecs of the catalog position to be
|
||||
// accepted as a cross-reference (guards against coincidental name/position collisions).
|
||||
const MATCH_TOLERANCE_PC = 0.5;
|
||||
// The cache is keyed by the request it answers — endpoint included, since the cache records
|
||||
// only that some response arrived: one cached before a column was added would otherwise keep
|
||||
// serving rows without it, and a missing proper-motion cell reads as "does not move",
|
||||
// silently wrong rather than visibly broken.
|
||||
const CACHE_FILE = `exoplanet-archive-ps-${createHash('sha1').update(TAP_URL).digest('hex').slice(0, 8)}.csv`;
|
||||
|
||||
/**
|
||||
* Downloads confirmed exoplanets from the NASA Exoplanet Archive (`Planetary Systems` TAP
|
||||
@@ -45,7 +50,7 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
|
||||
const knownStars = stars ?? (await fetchStars());
|
||||
const nameIndex = buildStarNameIndex(knownStars);
|
||||
|
||||
const csv = await fetchTextCached(TAP_URL, 'exoplanet-archive-ps.csv');
|
||||
const csv = await fetchTextCached(TAP_URL, CACHE_FILE);
|
||||
const rows = parseCsvObjects(csv);
|
||||
|
||||
let matched = 0;
|
||||
@@ -55,11 +60,12 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
|
||||
const raDeg = parseOptionalNumber(row['ra']) ?? Number.NaN;
|
||||
const decDeg = parseOptionalNumber(row['dec']) ?? Number.NaN;
|
||||
const distancePc = parseOptionalNumber(row['sy_dist']) ?? Number.NaN;
|
||||
const pmRaMasPerYear = parseOptionalNumber(row['sy_pmra']);
|
||||
const pmDecMasPerYear = parseOptionalNumber(row['sy_pmdec']);
|
||||
|
||||
const hostStarId = resolveHostStarId(
|
||||
{ hostname: row['hostname'], raDeg, decDeg, distancePc },
|
||||
{ hostname: row['hostname'], raDeg, decDeg, distancePc, pmRaMasPerYear, pmDecMasPerYear },
|
||||
knownStars,
|
||||
MATCH_TOLERANCE_PC,
|
||||
nameIndex
|
||||
);
|
||||
if (hostStarId !== null) {
|
||||
@@ -84,6 +90,8 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
|
||||
hostRaDeg: parseOptionalNumber(row['ra']),
|
||||
hostDecDeg: parseOptionalNumber(row['dec']),
|
||||
hostDistancePc: parseOptionalNumber(row['sy_dist']),
|
||||
hostPmRaMasPerYear: pmRaMasPerYear,
|
||||
hostPmDecMasPerYear: pmDecMasPerYear,
|
||||
orbit: {
|
||||
semiMajorAxisAu: parseOptionalNumber(row['pl_orbsmax']),
|
||||
eccentricity: parseOptionalNumber(row['pl_orbeccen']),
|
||||
|
||||
+59
-26
@@ -1,9 +1,9 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
import { raDecDistanceToXyz } from '../../src/app/shared/astro/coordinates';
|
||||
import { mergeStarCatalogues } from '../../src/app/shared/astro/star-merge';
|
||||
import { mergeStarCatalogues, placementDistancePc } from '../../src/app/shared/astro/star-merge';
|
||||
import { encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
|
||||
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
||||
import { fetchGaiaDistancesByHip, GaiaAnswerError } from './sources/gaia';
|
||||
import { positionalSources } from './sources/registry';
|
||||
import { PARALLAX_PRECISION_MAS } from './sources/star-sources';
|
||||
import { parseCsvObjects, parseOptionalNumber } from './lib/csv';
|
||||
@@ -20,13 +20,14 @@ const HYG_UNKNOWN_DISTANCE_PC = 100000; // HYG's placeholder for unmeasured/unre
|
||||
const UNKNOWN_MAGNITUDE = 15;
|
||||
|
||||
/**
|
||||
* Stars within this distance (parsecs) of the Sun are kept for the galaxy view.
|
||||
* Stars either survey places within this distance (parsecs) of the Sun are kept for the galaxy
|
||||
* view; `placementDistancePc` decides which distance a kept star is drawn at.
|
||||
*
|
||||
* Set at the range HYG's own measurements reach rather than at a round number. 98.6% of its
|
||||
* rows carry a Hipparcos identifier, and Hipparcos parallaxes are good to roughly a
|
||||
* milliarcsecond — so at 250 pc (4 mas) a star's distance is uncertain by some tens of per
|
||||
* cent, and beyond it the catalogue is plotting noise. Note that only the *radial* placement
|
||||
* blurs: a star's direction on the sky stays exact at any distance.
|
||||
* Set at the range Hipparcos's own measurements reach rather than at a round number: its
|
||||
* parallaxes are good to roughly a milliarcsecond, so at 250 pc (4 mas) a distance is uncertain
|
||||
* by some tens of per cent. That is why it is not applied to the Hipparcos distance alone:
|
||||
* Gaia puts 6 833 of the stars Hipparcos places inside it outside, and 3 666 the other way
|
||||
* round. Only the *radial* placement blurs; a star's direction on the sky stays exact.
|
||||
*
|
||||
* The catalogue is also magnitude-limited, so this is not a volume-complete sample beyond about
|
||||
* 50 pc: it thins to the intrinsically bright, which is the same selection the naked eye makes.
|
||||
@@ -59,51 +60,76 @@ function resolveName(row: Record<string, string>): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the HYG (Hipparcos/Yale/Gliese) stellar database, converts each star's
|
||||
* RA/Dec/distance into galaxy-scale Cartesian coordinates (parsecs), filters by distance,
|
||||
* and writes `stars.bin` (packed positions) + `stars-index.json` (everything else).
|
||||
* Downloads the HYG (Hipparcos/Yale/Gliese) stellar database, places each star along its
|
||||
* equatorial direction (epoch J2000.0) at the better of its Hipparcos and Gaia distances, keeps
|
||||
* the ones either survey puts within range, unions the other positional sources, and writes
|
||||
* `stars.bin` (packed positions) + `stars-index.json` (everything else).
|
||||
*/
|
||||
export async function fetchStars(): Promise<StarRecord[]> {
|
||||
console.log(`Fetching HYG star catalog (distance cutoff: ${DISTANCE_CUTOFF_PC} pc)...`);
|
||||
const csv = await fetchTextCached(HYG_CSV_URL, 'hygdata_v41.csv');
|
||||
const rows = parseCsvObjects(csv);
|
||||
// Not skipped when unreachable, unlike the positional sources below; see its own comment.
|
||||
const gaiaPcByHip = await fetchGaiaDistancesByHip();
|
||||
|
||||
const stars: StarRecord[] = [];
|
||||
let atGaiaDistance = 0;
|
||||
let pastCutoff = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const id = Number(row['id']);
|
||||
const distancePc = Number(row['dist']);
|
||||
|
||||
if (id === SUN_STAR_ID) {
|
||||
stars.push({ id, name: 'Sol', x: 0, y: 0, z: 0, magnitude: parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE, spectralType: row['spect'] || 'G2V', colorIndex: parseOptionalNumber(row['ci']) ?? null });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(distancePc) || distancePc >= HYG_UNKNOWN_DISTANCE_PC || distancePc > DISTANCE_CUTOFF_PC) {
|
||||
const hygPc = Number(row['dist']);
|
||||
const hipparcosPc = Number.isFinite(hygPc) && hygPc > 0 && hygPc < HYG_UNKNOWN_DISTANCE_PC ? hygPc : undefined;
|
||||
const gaiaPc = row['hip'] ? gaiaPcByHip.get(Number(row['hip'])) : undefined;
|
||||
const distancePc = placementDistancePc(hipparcosPc, gaiaPc, DISTANCE_CUTOFF_PC);
|
||||
if (distancePc === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const raHours = Number(row['ra']);
|
||||
const decDeg = Number(row['dec']);
|
||||
if (!Number.isFinite(raHours) || !Number.isFinite(decDeg)) {
|
||||
// HYG's own Cartesian columns rather than its `ra`/`dec`, which are in the same frame as
|
||||
// `raDecDistanceToXyz` and would be redundant if the two agreed. They do not, for the stars
|
||||
// that move: the right ascension was carried from the Hipparcos epoch to 2000.0 without the
|
||||
// cos δ its motion needs, which puts Proxima 17.9″ from where HYG's own x/y/z — and Gaia,
|
||||
// once brought to the same epoch — have it. 1813 stars differ by over an arcsecond, and the
|
||||
// Cartesian columns are the ones Gaia agrees with for 1155 of them against 156 (one of those,
|
||||
// HIP 57146, has x/y/z 161″ from its own ra/dec and stays double).
|
||||
//
|
||||
// Only their direction is used. They sit at HYG's own distance, or at its 100 000 pc
|
||||
// placeholder where it has none, and are carried along that direction to the one chosen above.
|
||||
const x = Number(row['x']);
|
||||
const y = Number(row['y']);
|
||||
const z = Number(row['z']);
|
||||
const length = Math.hypot(x, y, z);
|
||||
if (![x, y, z].every(Number.isFinite) || length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { x, y, z } = raDecDistanceToXyz(raHours, decDeg, distancePc);
|
||||
const scale = distancePc / length;
|
||||
if (gaiaPc !== undefined) {
|
||||
atGaiaDistance++;
|
||||
}
|
||||
if (distancePc > DISTANCE_CUTOFF_PC) {
|
||||
pastCutoff++;
|
||||
}
|
||||
|
||||
stars.push({
|
||||
id,
|
||||
name: resolveName(row),
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
x: x * scale,
|
||||
y: y * scale,
|
||||
z: z * scale,
|
||||
magnitude: parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE,
|
||||
spectralType: row['spect'] || 'Unknown',
|
||||
colorIndex: parseOptionalNumber(row['ci']) ?? null
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` kept ${stars.length} stars (of ${rows.length} in the catalog).`);
|
||||
console.log(` kept ${stars.length} stars (of ${rows.length} in the catalog): ${atGaiaDistance} at Gaia's distance, ${pastCutoff} of them past ${DISTANCE_CUTOFF_PC} pc.`);
|
||||
|
||||
const merged = await mergeWithOtherSources(stars);
|
||||
merged.sort((a, b) => a.id - b.id);
|
||||
@@ -114,9 +140,11 @@ export async function fetchStars(): Promise<StarRecord[]> {
|
||||
/**
|
||||
* Unions HYG with every other positional source that is wired in and reachable.
|
||||
*
|
||||
* A source that cannot be reached is reported and skipped rather than failing the run. That is
|
||||
* not defensive padding: the archives this would draw on are frequently unavailable, and a build
|
||||
* that produces a smaller catalogue is far better than one that produces none.
|
||||
* A source that cannot be reached is reported and skipped here rather than thrown, so a run still
|
||||
* gets as far as validation and says what it has. Whether that may be published is decided
|
||||
* there: `validateMerge` in build.ts refuses a catalogue Gaia contributed nothing to. A source
|
||||
* that answered with something unusable ({@link GaiaAnswerError}) is a different matter, and stops
|
||||
* the run where it happened rather than being reported later as an outage.
|
||||
*/
|
||||
async function mergeWithOtherSources(hygStars: StarRecord[]): Promise<StarRecord[]> {
|
||||
const others = positionalSources().filter((source) => source.id !== 'hyg');
|
||||
@@ -134,6 +162,11 @@ async function mergeWithOtherSources(hygStars: StarRecord[]): Promise<StarRecord
|
||||
stars: await source.fetch!()
|
||||
});
|
||||
} catch (error) {
|
||||
// An answer that cannot be worked with is not an outage: skipping it would write a
|
||||
// half-catalogue over the published assets before the merge gate got to say so.
|
||||
if (error instanceof GaiaAnswerError) {
|
||||
throw error;
|
||||
}
|
||||
console.log(` skipping ${source.name}: ${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
}
|
||||
@@ -143,7 +176,7 @@ async function mergeWithOtherSources(hygStars: StarRecord[]): Promise<StarRecord
|
||||
}
|
||||
|
||||
const { stars, summary } = mergeStarCatalogues(candidates);
|
||||
console.log(` merged ${summary.total} stars from ${candidates.length} catalogues (${summary.duplicates} duplicates resolved to the better parallax):`);
|
||||
console.log(` merged ${summary.total} stars from ${candidates.length} catalogues (${summary.duplicates} entries folded into a better-measured one):`);
|
||||
for (const [sourceId, count] of Object.entries(summary.bySource)) {
|
||||
console.log(` ${sourceId}: ${count}`);
|
||||
}
|
||||
|
||||
+32
-5
@@ -17,17 +17,44 @@ export async function fetchTextCached(url: string, cacheKey: string): Promise<st
|
||||
}
|
||||
|
||||
console.log(` fetching ${url}`);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
const text = await fetchText(url);
|
||||
|
||||
mkdirSync(dirname(cachePath), { recursive: true });
|
||||
writeFileSync(cachePath, text, 'utf-8');
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to wait before each retry of a failed request. The archives this reads are public
|
||||
* services that time out under load — the Gaia TAP has answered a five-row join in two and a
|
||||
* half minutes and a full one with a 500 — and a weekly refresh that gives up on the first of
|
||||
* those publishes nothing that week.
|
||||
*/
|
||||
const RETRY_DELAYS_MS = [30_000, 120_000];
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
let response = await fetch(url).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))));
|
||||
if (!(response instanceof Error) && response.ok) {
|
||||
// Read inside the loop, because the body is where these downloads fail: the Gaia CSV is
|
||||
// 57 MB, and a connection reset part-way through rejects here, long after the 200.
|
||||
const body = await response.text().catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))));
|
||||
if (typeof body === 'string') {
|
||||
return body;
|
||||
}
|
||||
response = body;
|
||||
}
|
||||
const reason = response instanceof Error ? response.message : `${response.status} ${response.statusText}`;
|
||||
// A 4xx is the request's own fault, and waiting will not change the answer.
|
||||
const retryable = response instanceof Error || response.status >= 500;
|
||||
if (!retryable || attempt >= RETRY_DELAYS_MS.length) {
|
||||
throw new Error(`Failed to fetch ${url}: ${reason}`);
|
||||
}
|
||||
console.log(` ${reason}; trying again in ${RETRY_DELAYS_MS[attempt] / 1000} s`);
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS_MS[attempt]));
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience wrapper around {@link fetchTextCached} that parses the cached response as JSON. */
|
||||
export async function fetchJsonCached<T>(url: string, cacheKey: string): Promise<T> {
|
||||
return JSON.parse(await fetchTextCached(url, cacheKey)) as T;
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
import { RematchSummary, rematchHostStars } from '../../src/app/shared/astro/host-star-matching';
|
||||
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
|
||||
import { StarRecord } from '../../src/app/shared/models/star.model';
|
||||
import { dataPath } from './lib/paths';
|
||||
|
||||
/**
|
||||
* Reads the written assets, re-resolves every exoplanet's host star against the given catalogue,
|
||||
* and writes the exoplanets back. The matching itself lives with the matcher, in
|
||||
* `host-star-matching.ts`; this is only the file handling around it.
|
||||
*/
|
||||
export function rematchWrittenAssets(stars: readonly StarRecord[]): RematchSummary {
|
||||
const exoplanets = JSON.parse(readFileSync(dataPath('exoplanets.json'), 'utf8')) as ExoplanetRecord[];
|
||||
const summary = rematchHostStars(exoplanets, stars);
|
||||
writeFileSync(dataPath('exoplanets.json'), JSON.stringify(exoplanets));
|
||||
return summary;
|
||||
}
|
||||
+127
-13
@@ -1,4 +1,6 @@
|
||||
import { raDegDecDistanceToXyz } from '../../../src/app/shared/astro/coordinates';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { propagateProperMotion, raDegDecDistanceToXyz } from '../../../src/app/shared/astro/coordinates';
|
||||
import { StarRecord } from '../../../src/app/shared/models/star.model';
|
||||
import { parseCsvObjects, parseOptionalNumber } from '../lib/csv';
|
||||
import { fetchTextCached } from '../lib/http';
|
||||
@@ -11,14 +13,24 @@ import { fetchTextCached } from '../lib/http';
|
||||
* Galaxy — no catalogue is close to the rest — but within a few hundred parsecs it is complete
|
||||
* in a way Hipparcos never was, and its parallaxes are fifty times more precise.
|
||||
*
|
||||
* **This has never been run.** Every ESA, NOIRLab, SDSS and Euclid endpoint is unreachable from
|
||||
* the environment this was written in, so the query below is written against the published DR3
|
||||
* schema and has not been executed against it. Treat the column names as the first thing to
|
||||
* check if a real run misbehaves.
|
||||
* Written blind against the published DR3 schema, since no ESA endpoint was reachable from the
|
||||
* environment it was written in; first run for real by the scheduled refresh of 2026-08-24, which
|
||||
* fetched 412 765 rows.
|
||||
*/
|
||||
|
||||
const GAIA_TAP_URL = 'https://gea.esac.esa.int/tap-server/tap/sync';
|
||||
|
||||
/**
|
||||
* Gaia DR3 gives positions for J2016.0; HYG for J2000.0, which is the epoch this map keeps.
|
||||
* Sixteen years of proper motion is over an arcsecond for anything faster than ~62 mas/yr —
|
||||
* which is most of the nearest stars: 62″ for Proxima, 166″ for Barnard's — so as published, the
|
||||
* two catalogues never agree on where those stars are, and a merge that matched them on the sky
|
||||
* kept every one of them twice. Each position is therefore carried back to J2000.0 with Gaia's
|
||||
* own proper motion before it leaves here.
|
||||
*/
|
||||
const GAIA_DR3_EPOCH = 2016.0;
|
||||
const CATALOGUE_EPOCH = 2000.0;
|
||||
|
||||
/**
|
||||
* How far out to take Gaia, in parsecs, and the faintest star to keep.
|
||||
*
|
||||
@@ -26,9 +38,12 @@ const GAIA_TAP_URL = 'https://gea.esac.esa.int/tap-server/tap/sync';
|
||||
* past anything this map draws, so the limit here is a payload decision: the catalogue is baked
|
||||
* into a static asset that a browser downloads before the first frame.
|
||||
*/
|
||||
const DISTANCE_CUTOFF_PC = Number(process.env['ETL_GAIA_DISTANCE_PC'] ?? 250);
|
||||
const MAGNITUDE_LIMIT = Number(process.env['ETL_GAIA_MAGNITUDE_LIMIT'] ?? 12);
|
||||
const ROW_LIMIT = Number(process.env['ETL_GAIA_ROW_LIMIT'] ?? 500000);
|
||||
const DEFAULT_DISTANCE_CUTOFF_PC = 250;
|
||||
const DEFAULT_MAGNITUDE_LIMIT = 12;
|
||||
const DEFAULT_ROW_LIMIT = 500_000;
|
||||
const DISTANCE_CUTOFF_PC = Number(process.env['ETL_GAIA_DISTANCE_PC'] ?? DEFAULT_DISTANCE_CUTOFF_PC);
|
||||
const MAGNITUDE_LIMIT = Number(process.env['ETL_GAIA_MAGNITUDE_LIMIT'] ?? DEFAULT_MAGNITUDE_LIMIT);
|
||||
const ROW_LIMIT = Number(process.env['ETL_GAIA_ROW_LIMIT'] ?? DEFAULT_ROW_LIMIT);
|
||||
|
||||
/**
|
||||
* Relative parallax error above which a star is dropped: a parallax measured to worse than 20%
|
||||
@@ -44,15 +59,46 @@ function parallaxFloorMas(distancePc: number): number {
|
||||
function buildQuery(): string {
|
||||
return [
|
||||
`select top ${ROW_LIMIT}`,
|
||||
'source_id, ra, dec, parallax, parallax_error, phot_g_mean_mag, bp_rp',
|
||||
'source_id, ra, dec, pmra, pmdec, parallax, parallax_error, phot_g_mean_mag, bp_rp',
|
||||
'from gaiadr3.gaia_source',
|
||||
`where parallax > ${parallaxFloorMas(DISTANCE_CUTOFF_PC).toFixed(6)}`,
|
||||
`and parallax_over_error > ${(1 / MAX_PARALLAX_ERROR_RATIO).toFixed(1)}`,
|
||||
`and phot_g_mean_mag < ${MAGNITUDE_LIMIT}`,
|
||||
'order by phot_g_mean_mag asc'
|
||||
// source_id breaks the ties — 20 064 groups share a G at the published precision — so the
|
||||
// row order, and with it the ids assigned below, is a pure function of the archive's content.
|
||||
'order by phot_g_mean_mag asc, source_id asc'
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* How many rows the query above holds when nothing is overridden: 412 765, and DR3 is a finished
|
||||
* data release, so that number only moves when the query does. It lives here, under the query, so
|
||||
* that an edit to any of its filters is made with the count it invalidates in view.
|
||||
*
|
||||
* Checked because a short answer looks exactly like a complete one. The TAP service truncates on
|
||||
* its own timeout and still serves a well-formed CSV with a 200, and the rows are ordered by
|
||||
* magnitude, so what comes back is the bright half — the half HYG overlaps. The merge gate in
|
||||
* `build.ts` would then see Gaia stars present and a survivor count barely moved, and pass a
|
||||
* catalogue missing two hundred thousand stars, which the weekly job would publish and the runner
|
||||
* would cache for the weeks after it. Same failure, and same guard, as
|
||||
* {@link MIN_USABLE_HIP_DISTANCES} below.
|
||||
*
|
||||
* Only checked when nothing is overridden: the environment overrides exist to fetch a smaller
|
||||
* slice on purpose.
|
||||
*/
|
||||
const DEFAULT_QUERY_ROWS = 412_765;
|
||||
const MIN_ROW_SHARE = 0.95;
|
||||
|
||||
/**
|
||||
* An answer the archive gave that cannot be worked with, as against an archive that gave none.
|
||||
*
|
||||
* `fetchStars` skips a source it cannot reach and leaves the merge gate to judge the result. That
|
||||
* is right for an outage and wrong for a truncated CSV, which would be skipped, cached, and land
|
||||
* as "the archive was unreachable" long after the assets had been overwritten — so these throws
|
||||
* are marked, and rethrown there.
|
||||
*/
|
||||
export class GaiaAnswerError extends Error {}
|
||||
|
||||
/**
|
||||
* Gaia publishes no spectral classifications, but `bp_rp` is a colour index on the same footing
|
||||
* as HYG's `ci` — so the app's existing colour and spectral-class handling works unchanged, and
|
||||
@@ -69,11 +115,29 @@ const UNKNOWN_SPECTRAL_TYPE = 'Unknown';
|
||||
const GAIA_ID_BASE = 1_000_000_000;
|
||||
|
||||
export async function fetchGaiaStars(): Promise<StarRecord[]> {
|
||||
const url = `${GAIA_TAP_URL}?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&QUERY=${encodeURIComponent(buildQuery())}`;
|
||||
const query = buildQuery();
|
||||
const url = `${GAIA_TAP_URL}?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&QUERY=${encodeURIComponent(query)}`;
|
||||
console.log(`Fetching Gaia DR3 (within ${DISTANCE_CUTOFF_PC} pc, G < ${MAGNITUDE_LIMIT}, at most ${ROW_LIMIT} rows)...`);
|
||||
|
||||
const csv = await fetchTextCached(url, 'gaia-dr3.csv');
|
||||
// Keyed by the whole request, so a response cached for other columns, another order, or
|
||||
// another endpoint can never be mistaken for this one — the cache records only that some
|
||||
// response arrived, not what it answered.
|
||||
const cacheKey = `gaia-dr3-${createHash('sha1').update(url).digest('hex').slice(0, 8)}.csv`;
|
||||
const csv = await fetchTextCached(url, cacheKey);
|
||||
const rows = parseCsvObjects(csv);
|
||||
const jobsQuery = DISTANCE_CUTOFF_PC === DEFAULT_DISTANCE_CUTOFF_PC && MAGNITUDE_LIMIT === DEFAULT_MAGNITUDE_LIMIT && ROW_LIMIT === DEFAULT_ROW_LIMIT;
|
||||
if (jobsQuery && rows.length < DEFAULT_QUERY_ROWS * MIN_ROW_SHARE) {
|
||||
throw new GaiaAnswerError(
|
||||
`Gaia returned ${rows.length} rows, not the ~${DEFAULT_QUERY_ROWS} this query holds — the answer was cut short, it was an error page ` +
|
||||
`served with a 200, or the query was edited without updating DEFAULT_QUERY_ROWS; delete tools/etl/.cache/${cacheKey} once the archive answers properly`
|
||||
);
|
||||
}
|
||||
// Not gated on `jobsQuery` like the floor above it: the only ways to reach this cap are the
|
||||
// overrides that *widen* the query, and they are exactly when it is worth saying. What it must
|
||||
// not fire on is a deliberately smaller slice, where filling the limit is the whole point.
|
||||
if (ROW_LIMIT >= DEFAULT_ROW_LIMIT && rows.length >= ROW_LIMIT) {
|
||||
throw new GaiaAnswerError(`Gaia returned the query's own ${ROW_LIMIT}-row limit, so it is the limit deciding what the map holds; raise ETL_GAIA_ROW_LIMIT.`);
|
||||
}
|
||||
const stars: StarRecord[] = [];
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
@@ -89,7 +153,8 @@ export async function fetchGaiaStars(): Promise<StarRecord[]> {
|
||||
return;
|
||||
}
|
||||
|
||||
const { x, y, z } = raDegDecDistanceToXyz(raDeg, decDeg, distancePc);
|
||||
const j2000 = propagateProperMotion(raDeg, decDeg, parseOptionalNumber(row['pmra']) ?? 0, parseOptionalNumber(row['pmdec']) ?? 0, CATALOGUE_EPOCH - GAIA_DR3_EPOCH);
|
||||
const { x, y, z } = raDegDecDistanceToXyz(j2000.raDeg, j2000.decDeg, distancePc);
|
||||
stars.push({
|
||||
id: GAIA_ID_BASE + index,
|
||||
name: `Gaia DR3 ${row['source_id']}`,
|
||||
@@ -106,3 +171,52 @@ export async function fetchGaiaStars(): Promise<StarRecord[]> {
|
||||
console.log(` kept ${stars.length} Gaia stars (of ${rows.length} rows).`);
|
||||
return stars;
|
||||
}
|
||||
|
||||
/**
|
||||
* DR3's Hipparcos cross-match is a fixed table of 99 525 rows, 97 751 of them with a usable
|
||||
* parallax. Far fewer means the answer was an error page served with a 200, or was cut short,
|
||||
* and either would pass for "Gaia does not know these stars" and put every one of them back at
|
||||
* its Hipparcos distance.
|
||||
*/
|
||||
const MIN_USABLE_HIP_DISTANCES = 90_000;
|
||||
|
||||
/**
|
||||
* Gaia's distance for every Hipparcos star it has a usable parallax for, keyed by HIP number.
|
||||
*
|
||||
* Taken from the archive's own cross-match (`hipparcos2_best_neighbour`) rather than from
|
||||
* matching positions here, since Gaia's team made that identification star by star with the
|
||||
* proper motions and photometry in hand. It is deliberately not bounded by distance: the stars
|
||||
* it exists for are the ones Hipparcos put inside the map and Gaia puts outside, which the main
|
||||
* query above never fetches.
|
||||
*
|
||||
* Required rather than best effort. Without it every HYG star falls back to its Hipparcos
|
||||
* distance, the 6 833 that Gaia puts past 250 pc move back inside, and the published map would
|
||||
* flip between the two with the archive's availability.
|
||||
*/
|
||||
export async function fetchGaiaDistancesByHip(): Promise<Map<number, number>> {
|
||||
const query = [
|
||||
'select top 200000 b.original_ext_source_id as hip, g.parallax, g.parallax_over_error',
|
||||
'from gaiadr3.hipparcos2_best_neighbour b join gaiadr3.gaia_source g on g.source_id = b.source_id'
|
||||
].join(' ');
|
||||
const url = `${GAIA_TAP_URL}?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&QUERY=${encodeURIComponent(query)}`;
|
||||
console.log('Fetching Gaia DR3 distances for Hipparcos stars (archive cross-match)...');
|
||||
const rows = parseCsvObjects(await fetchTextCached(url, `gaia-dr3-hip-${createHash('sha1').update(url).digest('hex').slice(0, 8)}.csv`));
|
||||
|
||||
const distances = new Map<number, number>();
|
||||
for (const row of rows) {
|
||||
const hip = parseOptionalNumber(row['hip']);
|
||||
const parallaxMas = parseOptionalNumber(row['parallax']);
|
||||
const overError = parseOptionalNumber(row['parallax_over_error']);
|
||||
if (hip !== undefined && parallaxMas !== undefined && parallaxMas > 0 && overError !== undefined && overError > 1 / MAX_PARALLAX_ERROR_RATIO) {
|
||||
distances.set(hip, 1000 / parallaxMas);
|
||||
}
|
||||
}
|
||||
if (distances.size < MIN_USABLE_HIP_DISTANCES) {
|
||||
throw new Error(
|
||||
`the Hipparcos cross-match gave ${distances.size} usable distances (of ${rows.length} rows), not the ~97 751 it holds; ` +
|
||||
'delete tools/etl/.cache/gaia-dr3-hip-*.csv once the archive answers properly'
|
||||
);
|
||||
}
|
||||
console.log(` ${distances.size} Hipparcos stars have a Gaia distance (of ${rows.length} cross-matched).`);
|
||||
return distances;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ export const STAR_SOURCES: readonly StarSource[] = [
|
||||
name: 'HYG database (Hipparcos, Yale Bright Star, Gliese)',
|
||||
role: 'positional',
|
||||
endpoint: 'https://raw.githubusercontent.com/astronexus/HYG-Database',
|
||||
contributes: 'A complete, named, spectrally classified bright-star catalogue with parallaxes — 68388 stars within 250 pc.',
|
||||
contributes:
|
||||
'A complete, named, spectrally classified bright-star catalogue. Its Hipparcos parallaxes give way to Gaia’s wherever Gaia has a usable one, so its stars sit where the better measurement puts them.',
|
||||
unimplementedBecause: null
|
||||
},
|
||||
{
|
||||
|
||||
+2
-1
@@ -9,6 +9,7 @@
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.worker.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.worker.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/worker",
|
||||
"lib": ["es2022", "webworker"],
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.worker.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user