Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c38a42cbcb | ||
|
|
468c98b14a | ||
|
|
4b276e44a5 | ||
|
|
225d676ab0 | ||
|
|
3f0abf8717 | ||
|
|
e45c3b6287 | ||
|
|
2d06408c3f | ||
|
|
56870fd9fe | ||
|
|
cea39c7186 | ||
|
|
03b3d3b2d6 | ||
|
|
0c5efec505 | ||
|
|
337602f606 | ||
|
|
1a5785fb63 | ||
|
|
dd56eafba4 | ||
|
|
7fba48c808 | ||
|
|
1a53f26474 | ||
|
|
b7f277ea04 | ||
|
|
7ab92e61a1 | ||
|
|
365252f534 | ||
|
|
5dec528cee | ||
|
|
7971ec4007 | ||
|
|
862fb65ea4 | ||
|
|
44f6a8d086 | ||
|
|
b4017fcafc | ||
|
|
539a0f0a3e | ||
|
|
bd5e9d4b0d | ||
|
|
45d5209433 | ||
|
|
977a4d8a92 | ||
|
|
3026cbde5f | ||
|
|
c65a02e9bc | ||
|
|
9631ddf0a4 | ||
|
|
9ad0815acd | ||
|
|
a769d70015 | ||
|
|
52c5d3b144 | ||
|
|
6cd0666067 | ||
|
|
7064e34d02 | ||
|
|
c6206a8311 | ||
|
|
c77d3ed1d9 | ||
|
|
96348161cc | ||
|
|
962cb3f6bc | ||
|
|
e986f6bebf | ||
|
|
7efcd2e93a | ||
|
|
e1c806d05b | ||
|
|
58656a5e34 | ||
|
|
f7a482b06a | ||
|
|
011ebe1356 | ||
|
|
fd5a24ce72 | ||
|
|
f156e03822 | ||
|
|
965739e99e | ||
|
|
86e143131e | ||
|
|
c3fcb2e481 | ||
|
|
b071d87d8a | ||
|
|
7f8fb59f5d | ||
|
|
8c69a7a8b2 | ||
|
|
2d997e41db | ||
|
|
0a0b301807 | ||
|
|
7f187e000e | ||
|
|
efe6667b00 | ||
|
|
43b9b1f081 | ||
|
|
4eb61ff58e | ||
|
|
29d3ddb6ef | ||
|
|
f935bae3b3 | ||
|
|
db511f7aa4 | ||
|
|
037545d036 | ||
|
|
3df5396349 | ||
|
|
080bbe16dc | ||
|
|
9cef316359 | ||
|
|
dc2ce08694 | ||
|
|
08534279fb | ||
|
|
043d57f0e7 |
@@ -37,14 +37,19 @@ jobs:
|
|||||||
# different dependency tree than the one committed.
|
# different dependency tree than the one committed.
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|
||||||
# Four TypeScript projects, checked by four different things. These two have no build of
|
# Five TypeScript projects, checked by four different things. These three have no build of
|
||||||
# their own, so nothing else would ever compile them.
|
# 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
|
- name: Typecheck the ETL
|
||||||
run: npm run etl:typecheck
|
run: npm run etl:typecheck
|
||||||
|
|
||||||
- name: Typecheck the end-to-end tests
|
- name: Typecheck the end-to-end tests
|
||||||
run: npm run e2e:typecheck
|
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.
|
# `tsconfig.spec.json` is compiled here, `tsconfig.app.json` by the build below.
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: npm test -- --no-watch
|
run: npm test -- --no-watch
|
||||||
|
|||||||
@@ -39,9 +39,21 @@ jobs:
|
|||||||
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|
||||||
# The ETL's cache directory is gitignored and this is a fresh runner, so every source is
|
# Gaia DR3 is a frozen release: the same query returns the same bytes (a live re-fetch has
|
||||||
# fetched live (~50-100 MB). A failed fetch fails the run by design — no refresh is
|
# reproduced stars.bin exactly), so its responses are carried from one run to the next
|
||||||
# better than a partial one — except Gaia, which the ETL itself treats as best-effort.
|
# 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
|
- name: Rebuild the datasets
|
||||||
run: npm run etl
|
run: npm run etl
|
||||||
|
|
||||||
|
|||||||
@@ -40,4 +40,23 @@ test.describe('Galaxy view', () => {
|
|||||||
await page.getByRole('button', { name: 'Solar Neighbourhood' }).click();
|
await page.getByRole('button', { name: 'Solar Neighbourhood' }).click();
|
||||||
await expect(page.getByTestId('hud-title')).toHaveText('Local Stars', { timeout: 15_000 });
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
"etl": "tsx tools/etl/build.ts",
|
"etl": "tsx tools/etl/build.ts",
|
||||||
"etl:typecheck": "tsc -p tools/etl/tsconfig.json --noEmit",
|
"etl:typecheck": "tsc -p tools/etl/tsconfig.json --noEmit",
|
||||||
"e2e": "playwright test",
|
"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,
|
"private": true,
|
||||||
"packageManager": "npm@11.12.1",
|
"packageManager": "npm@11.12.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import * as THREE from 'three/webgpu';
|
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 { DataLoaderService, StarField } from '../../core/data/data-loader.service';
|
||||||
import { EngineService, EngineTickCallback } from '../../core/engine/engine.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 { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||||
import { StarRecord } from '../../shared/models/star.model';
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||||
|
import { LinkBudget } from '../../shared/astro/jump-links';
|
||||||
|
import { HudDisplay } from '../hud/hud-dock.component';
|
||||||
import { GalaxySystemSceneComponent } from './galaxy-system-scene.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
|
// jsdom does not implement ResizeObserver; the component only uses it to react to real
|
||||||
// layout changes, which never happen in this headless test.
|
// 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 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 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 STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA];
|
||||||
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
|
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
|
||||||
@@ -94,14 +101,18 @@ class FakeEngineService {
|
|||||||
|
|
||||||
setProjection(projection: 'perspective' | 'orthographic', distanceToTarget: number): void {
|
setProjection(projection: 'perspective' | 'orthographic', distanceToTarget: number): void {
|
||||||
this.projection = projection;
|
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);
|
const halfHeight = Math.max(distanceToTarget, 1e-6) * Math.tan((this.camera.fov * Math.PI) / 360);
|
||||||
this.orthographic.top = halfHeight;
|
this.orthographic.top = halfHeight;
|
||||||
this.orthographic.bottom = -halfHeight;
|
this.orthographic.bottom = -halfHeight;
|
||||||
this.orthographic.left = -halfHeight * this.camera.aspect;
|
this.orthographic.left = -halfHeight * this.camera.aspect;
|
||||||
this.orthographic.right = halfHeight * this.camera.aspect;
|
this.orthographic.right = halfHeight * this.camera.aspect;
|
||||||
this.orthographic.zoom = 1;
|
|
||||||
this.orthographic.position.copy(this.camera.position);
|
|
||||||
this.orthographic.quaternion.copy(this.camera.quaternion);
|
|
||||||
this.orthographic.updateProjectionMatrix();
|
this.orthographic.updateProjectionMatrix();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +135,13 @@ class FakeEngineService {
|
|||||||
|
|
||||||
resize(): void {}
|
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. */
|
/** Test helper: simulates one rendered frame by invoking every registered tick callback. */
|
||||||
tick(deltaSeconds: number): void {
|
tick(deltaSeconds: number): void {
|
||||||
for (const callback of this.tickCallbacks) {
|
for (const callback of this.tickCallbacks) {
|
||||||
@@ -200,6 +218,602 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
|||||||
expect(navigationStore.viewLevel()).toBe('galaxy');
|
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 () => {
|
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);
|
navigationStore.selectStar(SUN.id);
|
||||||
await flushAsync();
|
await flushAsync();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,5 @@
|
|||||||
import * as THREE from 'three/webgpu';
|
import * as THREE from 'three/webgpu';
|
||||||
|
|
||||||
import { JumpLink } from '../../shared/astro/jump-links';
|
|
||||||
|
|
||||||
/** Faint, because there are tens of thousands of them and none is worth reading on its own. */
|
/** Faint, because there are tens of thousands of them and none is worth reading on its own. */
|
||||||
const LINK_OPACITY = 0.16;
|
const LINK_OPACITY = 0.16;
|
||||||
/** The one route is the figure; the graph it is drawn on is the ground. */
|
/** The one route is the figure; the graph it is drawn on is the ground. */
|
||||||
@@ -48,23 +46,17 @@ export class JumpLinkRenderer {
|
|||||||
this.links.frustumCulled = false;
|
this.links.frustumCulled = false;
|
||||||
this.route.frustumCulled = false;
|
this.route.frustumCulled = false;
|
||||||
this.object.add(this.links, this.route);
|
this.object.add(this.links, this.route);
|
||||||
this.setLinks([], () => undefined);
|
this.setSegments(new Float32Array(0));
|
||||||
this.setRoute([], () => undefined);
|
this.setRoute([], () => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLinks(links: readonly JumpLink[], positionOf: (starId: number) => LinkPoint | undefined): void {
|
/** The graph, as vertex pairs: six floats a link, one end then the other. See `jumpLinkSegments`. */
|
||||||
const vertices = new Float32Array(links.length * 6);
|
setSegments(vertices: Float32Array): void {
|
||||||
let at = 0;
|
this.replaceGeometry(this.links, vertices);
|
||||||
for (const link of links) {
|
// Given rather than left for the renderer to compute: it wants a bounding sphere to sort by and,
|
||||||
const from = positionOf(link.from);
|
// finding none, walks every vertex on the main thread in the first frame that draws the graph.
|
||||||
const to = positionOf(link.to);
|
// The graph is never culled, and it sorts by its centre, where the catalogue is centred too.
|
||||||
if (!from || !to) {
|
this.links.geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), Infinity);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
vertices.set([from.x, from.y, from.z, to.x, to.y, to.z], at);
|
|
||||||
at += 6;
|
|
||||||
}
|
|
||||||
this.replaceGeometry(this.links, at === vertices.length ? vertices : vertices.subarray(0, at));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The chain to draw over the graph, departure first. Fewer than two stars draws nothing. */
|
/** The chain to draw over the graph, departure first. Fewer than two stars draws nothing. */
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -172,6 +172,25 @@ describe('StarFieldRenderer', () => {
|
|||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores a star just outside the frame, however close the pointer gets to the edge', () => {
|
||||||
|
// Its hit area is the drawn size plus a slop, so near an edge that area reaches past the
|
||||||
|
// frame — and a system nobody can see is not one a click should fly into.
|
||||||
|
const offScreen = [star({ id: 9, x: 0, y: 0, z: -10, magnitude: -2 })];
|
||||||
|
const renderer = new StarFieldRenderer(offScreen, packPositions(offScreen));
|
||||||
|
const centre = new THREE.Vector3(0, 0, -10).project(camera);
|
||||||
|
expect(renderer.pickAt(new THREE.Vector2(centre.x, centre.y), camera, camera.aspect)).toBe(9);
|
||||||
|
|
||||||
|
// The same star, just outside the top of the frame: its centre at NDC 1.01, its disc ending at
|
||||||
|
// 1.0033. A click at 0.995 is within its hit radius (0.0167) — so without the frame test this
|
||||||
|
// picks it — while none of the star is on screen.
|
||||||
|
const above = [star({ id: 9, x: 0, y: 10 * Math.tan((camera.fov * Math.PI) / 360) * 1.01, z: -10, magnitude: -2 })];
|
||||||
|
const outside = new StarFieldRenderer(above, packPositions(above));
|
||||||
|
|
||||||
|
expect(outside.pickAt(new THREE.Vector2(0, 0.995), camera, camera.aspect)).toBeUndefined();
|
||||||
|
renderer.dispose();
|
||||||
|
outside.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it('picks the star nearest the pointer when several are in view', () => {
|
it('picks the star nearest the pointer when several are in view', () => {
|
||||||
const spread = [
|
const spread = [
|
||||||
star({ id: 1, x: 0, y: 0, z: -10 }),
|
star({ id: 1, x: 0, y: 0, z: -10 }),
|
||||||
@@ -260,11 +279,6 @@ describe('selectDrawnStars', () => {
|
|||||||
expect(drawn).toEqual([0, 2, 3]);
|
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', () => {
|
describe('StarFieldRenderer render budget', () => {
|
||||||
@@ -291,3 +305,212 @@ describe('StarFieldRenderer render budget', () => {
|
|||||||
renderer.dispose();
|
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,6 +1,7 @@
|
|||||||
import * as THREE from 'three/webgpu';
|
import * as THREE from 'three/webgpu';
|
||||||
import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
|
import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
|
||||||
|
|
||||||
|
import { BrightnessIndex, brightnessIndex, Positioned } from '../../shared/astro/brightest';
|
||||||
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
|
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
|
||||||
import { SceneCamera } from '../../core/engine/engine.service';
|
import { SceneCamera } from '../../core/engine/engine.service';
|
||||||
import { StarRecord } from '../../shared/models/star.model';
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
@@ -25,25 +26,25 @@ const PICK_NDC_SLOP = 0.01;
|
|||||||
/**
|
/**
|
||||||
* How many stars the field draws at once, however many the catalogue holds.
|
* 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
|
* The *data* is the catalogue and the *drawing* is a budget, and the two are allowed to differ:
|
||||||
* them is a cost paid every frame by every machine, and most of that cost buys 1.5-pixel dots.
|
* everything still exists for search, for flying to, and for hosting planets. Which stars fill
|
||||||
* So the *data* is the catalogue and the *drawing* is a budget, and the two are allowed to
|
* the budget follows the view; see {@link selectDrawnStars}.
|
||||||
* differ. Everything still exists for search, for flying to, and for hosting planets.
|
|
||||||
*
|
*
|
||||||
* Currently set to the whole catalogue, which is what a GPU should be asked to do — this is one
|
* The number is set by what the field looks like, before what it costs. The catalogue is
|
||||||
* instanced draw call, and a discrete card will not notice it. The budget still exists because
|
* 423 651 stars since Gaia, and drawn whole the opening view is a grey wash: the additive
|
||||||
* the catalogue is meant to grow past what any machine should draw at once: Gaia alone could
|
* blending of that many 1.5-pixel dots buries the labels, the rings on the planet hosts and the
|
||||||
* contribute a million stars, and at that point the selection below is what keeps the field
|
* grid. At 150 000 the wash has begun; at this budget the view reads. Measured at 1920 × 1080 on
|
||||||
* legible rather than a grey wash.
|
* 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
|
||||||
* Machines without a GPU do feel it. A software rasterizer measured here lost about a third of
|
* two-core Radeon, standing in for an entry-level laptop, pays about 4 ms a frame for every
|
||||||
* its frame rate per 12000 stars drawn; if that matters for a deployment, this is the one number
|
* 100 000 stars: 112 frames a second at this budget, 44 at the whole catalogue, and the same
|
||||||
* to turn down.
|
* 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
|
* 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
|
* is" — but it would drop the solar neighbourhood, because the nearest stars are overwhelmingly
|
||||||
@@ -51,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
|
* 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.
|
* is spent on the brightest of everything beyond it.
|
||||||
*
|
*
|
||||||
* Kept deliberately small against the catalogue's 250 pc reach. The guaranteed core occupies a
|
* The same holds wherever the view is looking. Before the drawn set followed the view, a region
|
||||||
* thousandth of that volume, so a generous radius spends most of the budget inside it and draws
|
* 150 pc out drew 49 of the 442 stars within this radius of it, and a route plotted there ran
|
||||||
* a dense knot surrounded by nothing — which is a worse picture than the smaller catalogue was.
|
* 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 COLD_STAR_COLOR = new THREE.Color(0.65, 0.75, 1.0);
|
||||||
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
|
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
|
||||||
@@ -101,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.
|
* coloured by spectral index and sized by magnitude.
|
||||||
*
|
*
|
||||||
* **Why billboards and not `THREE.Points`.** Point primitives are capped at a single pixel on
|
* **Why billboards and not `THREE.Points`.** Point primitives are capped at a single pixel on
|
||||||
@@ -116,50 +248,12 @@ function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometr
|
|||||||
* Sizes are angular (`sizeAttenuation = false`), so a star holds the same apparent size however
|
* 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
|
* 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.
|
* 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
|
* The instance buffers hold the budget, not the catalogue, and are rewritten in place when
|
||||||
* them. Returns them in catalogue order rather than in selection order, purely so the drawn set
|
* {@link refocus} changes which stars fill it.
|
||||||
* is stable and inspectable.
|
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* Reads a render budget override off the page URL (`?stars=20000`), falling back to the default.
|
|
||||||
*
|
|
||||||
* Two uses, one real and one incidental. The real one is a deployment or a machine that cannot
|
|
||||||
* draw the whole catalogue — a number in a URL beats a rebuild. The incidental one is the
|
|
||||||
* end-to-end suite, which runs against a software rasterizer whose frame rate is two orders of
|
|
||||||
* magnitude below a real GPU's: those tests are checking navigation and state, and making them
|
|
||||||
* wait on a rasterizer measures nothing about the app.
|
|
||||||
*/
|
|
||||||
export function starRenderBudgetFromUrl(search: string, fallback = STAR_RENDER_BUDGET): number {
|
|
||||||
const requested = Number(new URLSearchParams(search).get('stars'));
|
|
||||||
return Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectDrawnStars(stars: readonly StarRecord[], budget = STAR_RENDER_BUDGET): Uint32Array {
|
|
||||||
if (stars.length <= budget) {
|
|
||||||
return Uint32Array.from(stars.keys());
|
|
||||||
}
|
|
||||||
|
|
||||||
const near: number[] = [];
|
|
||||||
const far: number[] = [];
|
|
||||||
stars.forEach((star, index) => {
|
|
||||||
(Math.hypot(star.x, star.y, star.z) <= ALWAYS_DRAWN_RADIUS_PC ? near : far).push(index);
|
|
||||||
});
|
|
||||||
|
|
||||||
far.sort((a, b) => stars[a].magnitude - stars[b].magnitude);
|
|
||||||
const selected = near.concat(far.slice(0, Math.max(0, budget - near.length)));
|
|
||||||
selected.sort((a, b) => a - b);
|
|
||||||
return Uint32Array.from(selected);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StarFieldRenderer {
|
export class StarFieldRenderer {
|
||||||
readonly object: THREE.Mesh;
|
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`. */
|
/** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */
|
||||||
private readonly perspective = uniform(1);
|
private readonly perspective = uniform(1);
|
||||||
@@ -167,39 +261,47 @@ export class StarFieldRenderer {
|
|||||||
|
|
||||||
private readonly geometry: THREE.InstancedBufferGeometry;
|
private readonly geometry: THREE.InstancedBufferGeometry;
|
||||||
private readonly material: THREE.SpriteNodeMaterial;
|
private readonly material: THREE.SpriteNodeMaterial;
|
||||||
/** The subset of the catalogue that is drawn, and so the only set that can be clicked. */
|
private readonly budget: number;
|
||||||
private readonly stars: readonly StarRecord[];
|
private readonly brightness: BrightnessIndex;
|
||||||
/** Angular diameter per drawn star, in the same order as `stars` — reused for picking. */
|
/**
|
||||||
private readonly angularSizes: Float32Array;
|
* 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) {
|
/** Per-instance data, `budget` long; the first `drawnCount` entries are live. */
|
||||||
const drawn = selectDrawnStars(catalogue, budget);
|
private readonly positionAttribute: THREE.InstancedBufferAttribute;
|
||||||
this.stars = drawn.length === catalogue.length ? catalogue : Array.from(drawn, (index) => catalogue[index]);
|
private readonly colorAttribute: THREE.InstancedBufferAttribute;
|
||||||
this.drawnCount = this.stars.length;
|
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;
|
constructor(
|
||||||
this.geometry = createQuadGeometry(stars.length);
|
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.positionAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
|
||||||
this.angularSizes = new Float32Array(stars.length);
|
this.colorAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
|
||||||
// Repacked only when the drawn set is a subset; otherwise the ETL's buffer is used as-is.
|
this.sizeAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity), 1);
|
||||||
const positions =
|
|
||||||
drawn.length === catalogue.length
|
|
||||||
? cataloguePositions
|
|
||||||
: Float32Array.from({ length: drawn.length * 3 }, (_, i) => cataloguePositions[drawn[(i / 3) | 0] * 3 + (i % 3)]);
|
|
||||||
|
|
||||||
stars.forEach((star, index) => {
|
this.catalogueColors = new Float32Array(catalogue.length * 3);
|
||||||
|
this.catalogueSizes = new Float32Array(catalogue.length);
|
||||||
|
catalogue.forEach((star, index) => {
|
||||||
const color = colorIndexToRgb(star.colorIndex, star.spectralType);
|
const color = colorIndexToRgb(star.colorIndex, star.spectralType);
|
||||||
colors[index * 3] = color.r;
|
this.catalogueColors[index * 3] = color.r;
|
||||||
colors[index * 3 + 1] = color.g;
|
this.catalogueColors[index * 3 + 1] = color.g;
|
||||||
colors[index * 3 + 2] = color.b;
|
this.catalogueColors[index * 3 + 2] = color.b;
|
||||||
this.angularSizes[index] = magnitudeToPointSize(star.magnitude) * PIXELS_TO_ANGULAR_SIZE;
|
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({
|
this.material = new THREE.SpriteNodeMaterial({
|
||||||
transparent: true,
|
transparent: true,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
@@ -212,15 +314,15 @@ export class StarFieldRenderer {
|
|||||||
// a thousandth of a parsec — invisible. Doing the same arithmetic in the node graph, behind
|
// 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.
|
// a uniform, lets one material serve both cameras without being recompiled between them.
|
||||||
this.material.sizeAttenuation = true;
|
this.material.sizeAttenuation = true;
|
||||||
const position = instancedBufferAttribute<'vec3'>(positionAttribute, 'vec3');
|
const position = instancedBufferAttribute<'vec3'>(this.positionAttribute, 'vec3');
|
||||||
const angularSize = instancedBufferAttribute<'float'>(sizeAttribute, 'float');
|
const angularSize = instancedBufferAttribute<'float'>(this.sizeAttribute, 'float');
|
||||||
this.material.positionNode = position;
|
this.material.positionNode = position;
|
||||||
// Perspective: a star's world size is its angular size times how far away it is, which is
|
// 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,
|
// 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.
|
// the frustum does, so the same angular size is scaled by the frustum instead.
|
||||||
const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate();
|
const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate();
|
||||||
this.material.scaleNode = angularSize.mul(mix(this.orthographicScale, viewDepth, this.perspective));
|
this.material.scaleNode = angularSize.mul(mix(this.orthographicScale, viewDepth, this.perspective));
|
||||||
this.material.colorNode = instancedBufferAttribute<'vec3'>(colorAttribute, 'vec3');
|
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
|
// 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.
|
// hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre.
|
||||||
const radius = uv().sub(vec2(0.5)).length();
|
const radius = uv().sub(vec2(0.5)).length();
|
||||||
@@ -230,6 +332,51 @@ export class StarFieldRenderer {
|
|||||||
// The quad's own bounds sit at the origin and say nothing about where the instances are,
|
// 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.
|
// so leaving culling on would drop the whole field whenever the origin left the frustum.
|
||||||
this.object.frustumCulled = false;
|
this.object.frustumCulled = false;
|
||||||
|
|
||||||
|
this.refocus({});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -245,9 +392,9 @@ export class StarFieldRenderer {
|
|||||||
this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360);
|
this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Looks up the HYG star id for a given instance index. */
|
/** Looks up the star id for a given instance index. */
|
||||||
starIdAt(instanceIndex: number): number | undefined {
|
starIdAt(instanceIndex: number): number | undefined {
|
||||||
return this.stars[instanceIndex]?.id;
|
return instanceIndex >= 0 && instanceIndex < this.drawn.length ? this.catalogue[this.drawn[instanceIndex]].id : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -259,6 +406,11 @@ export class StarFieldRenderer {
|
|||||||
* needed: each star is tested against the size it is actually drawn at, so the hit area matches
|
* needed: each star is tested against the size it is actually drawn at, so the hit area matches
|
||||||
* what the user sees at every zoom level instead of being over-permissive up close and
|
* what the user sees at every zoom level instead of being over-permissive up close and
|
||||||
* sub-pixel at the far end of the camera's range.
|
* sub-pixel at the far end of the camera's range.
|
||||||
|
*
|
||||||
|
* Only stars on screen can be picked. The hit area is the drawn size plus a slop of
|
||||||
|
* {@link PICK_NDC_SLOP}, and near an edge that slop reaches past the frame: a click in the
|
||||||
|
* last few pixels of the view used to be able to fly into a system whose star was outside it,
|
||||||
|
* with nothing on screen to explain where it had gone.
|
||||||
*/
|
*/
|
||||||
pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined {
|
pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined {
|
||||||
// What a unit of angular size is worth on screen. Under perspective the field of view sets
|
// What a unit of angular size is worth on screen. Under perspective the field of view sets
|
||||||
@@ -269,22 +421,29 @@ export class StarFieldRenderer {
|
|||||||
const perspective = (camera as THREE.PerspectiveCamera).isPerspectiveCamera;
|
const perspective = (camera as THREE.PerspectiveCamera).isPerspectiveCamera;
|
||||||
const tanHalfFov = Math.tan(((perspective ? (camera as THREE.PerspectiveCamera).fov : REFERENCE_FOV_DEGREES) * Math.PI) / 360);
|
const tanHalfFov = Math.tan(((perspective ? (camera as THREE.PerspectiveCamera).fov : REFERENCE_FOV_DEGREES) * Math.PI) / 360);
|
||||||
const projected = new THREE.Vector3();
|
const projected = new THREE.Vector3();
|
||||||
|
const positions = this.positionAttribute.array as Float32Array;
|
||||||
|
const sizes = this.sizeAttribute.array as Float32Array;
|
||||||
|
|
||||||
let bestIndex: number | undefined;
|
let bestIndex: number | undefined;
|
||||||
let bestScore = Infinity;
|
let bestScore = Infinity;
|
||||||
|
|
||||||
for (let index = 0; index < this.stars.length; index++) {
|
for (let index = 0; index < this.drawn.length; index++) {
|
||||||
const star = this.stars[index];
|
projected.set(positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]).project(camera);
|
||||||
projected.set(star.x, star.y, star.z).project(camera);
|
|
||||||
// Outside the depth range means behind the camera or beyond the far plane; `project`
|
// 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.
|
// mirrors points behind the camera onto the screen, so this guard is load-bearing.
|
||||||
if (projected.z < -1 || projected.z > 1) {
|
if (projected.z < -1 || projected.z > 1) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
|
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
|
||||||
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
|
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
|
||||||
const ndcRadius = (0.5 * this.angularSizes[index]) / tanHalfFov + PICK_NDC_SLOP;
|
const drawnRadius = (0.5 * sizes[index]) / tanHalfFov;
|
||||||
|
// Off screen if no part of the drawn disc is inside the frame. Tested before the slop is
|
||||||
|
// added: the slop is forgiveness for an imprecise click on a star you can see, not a reach
|
||||||
|
// past the edge to one you cannot.
|
||||||
|
if (Math.abs(projected.x) - drawnRadius / aspect > 1 || Math.abs(projected.y) - drawnRadius > 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ndcRadius = drawnRadius + PICK_NDC_SLOP;
|
||||||
const dx = (projected.x - pointerNdc.x) * aspect;
|
const dx = (projected.x - pointerNdc.x) * aspect;
|
||||||
const dy = projected.y - pointerNdc.y;
|
const dy = projected.y - pointerNdc.y;
|
||||||
const score = Math.hypot(dx, dy) / ndcRadius;
|
const score = Math.hypot(dx, dy) / ndcRadius;
|
||||||
@@ -295,7 +454,7 @@ export class StarFieldRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bestIndex === undefined ? undefined : this.stars[bestIndex].id;
|
return bestIndex === undefined ? undefined : this.starIdAt(bestIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
|||||||
@@ -79,4 +79,17 @@ describe('StarmapHudComponent', () => {
|
|||||||
// An empty nameplate is worse than none: it reads as a selection that failed to resolve.
|
// 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();
|
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,5 +1,6 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||||
|
|
||||||
|
import { ScaleBar } from '../../shared/format/scale-bar';
|
||||||
import { ViewLevel } from '../../shared/state/navigation.store';
|
import { ViewLevel } from '../../shared/state/navigation.store';
|
||||||
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
|
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
|
||||||
|
|
||||||
@@ -20,9 +21,9 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The top of the map's heads-up display: the scale ladder on the left, the nameplate across
|
* The top of the map's heads-up display: the scale ladder on the left with the scale bar under
|
||||||
* the centre, and a centre reticle on whatever the camera is holding. Readouts and tools live
|
* it, the nameplate across the centre, and a centre reticle on whatever the camera is holding.
|
||||||
* in the dock along the bottom (`HudDockComponent`).
|
* 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
|
* 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.
|
* request to move to another scale. The scene owns the camera and decides what that means.
|
||||||
@@ -68,6 +69,15 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
|||||||
}
|
}
|
||||||
</nav>
|
</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()) {
|
@if (title()) {
|
||||||
<!-- Hidden below lg: the readout panel names the same thing, and at narrower widths a
|
<!-- 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
|
long star name runs into the scale rail on its left and under the object card on its
|
||||||
@@ -86,6 +96,8 @@ export class StarmapHudComponent {
|
|||||||
/** What the view is holding, for the nameplate — the selected star, or nothing. */
|
/** What the view is holding, for the nameplate — the selected star, or nothing. */
|
||||||
readonly title = input('');
|
readonly title = input('');
|
||||||
readonly showReticle = input(true);
|
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>();
|
readonly levelSelected = output<ViewLevel>();
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/co
|
|||||||
import {
|
import {
|
||||||
bodyMarkerRadiusAu,
|
bodyMarkerRadiusAu,
|
||||||
DEFAULT_STAR_MARKER_RADIUS_AU,
|
DEFAULT_STAR_MARKER_RADIUS_AU,
|
||||||
starGlowExtentAu,
|
|
||||||
starMarkerRadiusAu,
|
starMarkerRadiusAu,
|
||||||
systemFrameRadiusAu,
|
systemFrameRadiusAu,
|
||||||
systemFramingDistanceAu,
|
systemFramingDistanceAu,
|
||||||
@@ -113,105 +112,6 @@ describe('systemFramingDistanceAu', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('starGlowExtentAu', () => {
|
|
||||||
/** A typical viewport, so a screen-space claim can be made in pixels rather than in ratios. */
|
|
||||||
const REFERENCE_VIEWPORT_HALF_HEIGHT_PX = 450;
|
|
||||||
|
|
||||||
/** The halo's visual radius, in AU, at the distance this system is framed from. */
|
|
||||||
function haloRadiusAu(innermostAu: number, outermostAu: number, glowScale = 1): number {
|
|
||||||
// The sprite's extent is its full width, so half of it is what reaches out from the star.
|
|
||||||
return starGlowExtentAu(starMarkerRadiusAu(innermostAu), frameRadiusFor(outermostAu), glowScale) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
function frameRadiusFor(outermostAu: number): number {
|
|
||||||
const rings = systemGridRingsAu(outermostAu);
|
|
||||||
return systemFrameRadiusAu(systemFramingDistanceAu(rings[rings.length - 1]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Apparent size on screen, as a fraction of the frame's half-height. */
|
|
||||||
function apparentFraction(innermostAu: number, outermostAu: number, glowScale = 1): number {
|
|
||||||
return haloRadiusAu(innermostAu, outermostAu, glowScale) / frameRadiusFor(outermostAu);
|
|
||||||
}
|
|
||||||
|
|
||||||
function apparentPixels(innermostAu: number, outermostAu: number): number {
|
|
||||||
return apparentFraction(innermostAu, outermostAu) * REFERENCE_VIEWPORT_HALF_HEIGHT_PX;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('scales with the star for a compact system, where the star is already big enough', () => {
|
|
||||||
// A tight frame relative to the star, so the star's own multiple is what decides.
|
|
||||||
const marker = 0.02;
|
|
||||||
const tightFrame = 0.5;
|
|
||||||
expect(starGlowExtentAu(marker, tightFrame)).toBeCloseTo(marker * 3.2, 9);
|
|
||||||
expect(starGlowExtentAu(marker * 2, tightFrame)).toBeCloseTo(marker * 2 * 3.2, 9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('floors against the frame once the star would otherwise vanish into it', () => {
|
|
||||||
// A star sized against a close-in orbit, framed from far enough out to hold a wide system:
|
|
||||||
// the multiple of the star is nothing, so the frame decides instead.
|
|
||||||
const tinyStar = 0.001;
|
|
||||||
const wideFrame = 56;
|
|
||||||
expect(starGlowExtentAu(tinyStar, wideFrame)).toBeGreaterThan(tinyStar * 3.2 * 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the Sun visible at the distance that frames the solar system', () => {
|
|
||||||
// The case that prompted this: the solar system spans a factor of a hundred from Mercury to
|
|
||||||
// Pluto, so a disc that stays clear of Mercury is about a pixel across once Pluto is in view.
|
|
||||||
expect(apparentPixels(0.387, 39.288)).toBeGreaterThan(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves the inner orbits clear of the halo', () => {
|
|
||||||
// The other half of the same trade. Venus and Earth have to stay legible as rings around the
|
|
||||||
// star, which bounds the halo from above just as visibility bounds it from below.
|
|
||||||
const halo = haloRadiusAu(0.387, 39.288);
|
|
||||||
const VENUS_AU = 0.723;
|
|
||||||
const EARTH_AU = 1;
|
|
||||||
expect(halo).toBeLessThan(VENUS_AU);
|
|
||||||
expect(halo).toBeLessThan(EARTH_AU);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cannot clear Mercury as well, and does not pretend to', () => {
|
|
||||||
// Mercury's orbit is 0.7% of the framed radius — about three pixels — so it is inside any
|
|
||||||
// halo big enough to see. Pinned so the trade is a decision rather than an oversight.
|
|
||||||
expect(haloRadiusAu(0.387, 39.288)).toBeGreaterThan(0.387);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('holds the floor across every system scale the datasets contain', () => {
|
|
||||||
// A compact system's star is genuinely large relative to its own system and keeps the bigger
|
|
||||||
// halo; the floor is not there to equalise them, only to stop the wide ones disappearing.
|
|
||||||
for (const [innermost, outermost] of [
|
|
||||||
[0.387, 39.288],
|
|
||||||
[0.035, 0.204],
|
|
||||||
[0.01154, 0.06189],
|
|
||||||
[1.2, 12.4]
|
|
||||||
]) {
|
|
||||||
expect(apparentPixels(innermost, outermost)).toBeGreaterThan(4);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not blot out the system it sits in', () => {
|
|
||||||
for (const [innermost, outermost] of [
|
|
||||||
[0.387, 39.288],
|
|
||||||
[0.035, 0.204],
|
|
||||||
[0.01154, 0.06189]
|
|
||||||
]) {
|
|
||||||
expect(apparentFraction(innermost, outermost)).toBeLessThan(0.2);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('dims for a star drawn from a colour rather than a photograph, but never below the floor', () => {
|
|
||||||
// Above the floor the multiplier applies...
|
|
||||||
expect(starGlowExtentAu(1, 10, 0.6)).toBeLessThan(starGlowExtentAu(1, 10, 1));
|
|
||||||
// ...and at the floor it cannot dim a star into invisibility.
|
|
||||||
expect(starGlowExtentAu(0.001, 56, 0.6)).toBe(starGlowExtentAu(0.001, 56, 1));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the star alone when there is no frame to measure against', () => {
|
|
||||||
for (const frame of [0, -1, Number.NaN]) {
|
|
||||||
expect(starGlowExtentAu(0.2, frame)).toBeCloseTo(0.2 * 3.2, 9);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('the grid and the framing together', () => {
|
describe('the grid and the framing together', () => {
|
||||||
/** What the scene actually composes: rings from the orbits, then a distance from the rings. */
|
/** What the scene actually composes: rings from the orbits, then a distance from the rings. */
|
||||||
function fit(outermostOrbitAu: number, viewport?: SystemViewport): { ring: number; frame: number } {
|
function fit(outermostOrbitAu: number, viewport?: SystemViewport): { ring: number; frame: number } {
|
||||||
@@ -278,54 +178,41 @@ describe('star and framing together', () => {
|
|||||||
|
|
||||||
describe('bodyMarkerRadiusAu', () => {
|
describe('bodyMarkerRadiusAu', () => {
|
||||||
const EARTH_RADIUS_KM = 6371;
|
const EARTH_RADIUS_KM = 6371;
|
||||||
const SOLAR_SPAN_AU = 30.07;
|
const KM_PER_AU = 149597870.7;
|
||||||
|
|
||||||
it('scales in proportion to the system span', () => {
|
it('draws a body at its true size', () => {
|
||||||
const wide = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU);
|
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM)).toBeCloseTo(EARTH_RADIUS_KM / KM_PER_AU, 12);
|
||||||
const compact = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU / 100);
|
expect(bodyMarkerRadiusAu(696340)).toBeCloseTo(0.00465, 5); // the Sun
|
||||||
|
|
||||||
expect(compact / wide).toBeCloseTo(0.01, 6);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a marker far smaller than the orbits it sits on, at any scale', () => {
|
it('keeps a moon smaller than its planet and outside it, which the exaggeration did not', () => {
|
||||||
// A fixed 0.09 AU marker inside Gl 357's 0.204 AU system was wider than the orbits, so one
|
// Jupiter and Ganymede both ran past the old 0.09 AU ceiling and came out one size, so
|
||||||
// planet swallowed the whole view.
|
// Ganymede orbited inside Jupiter; Phobos and Triton sat entirely within Mars and Neptune.
|
||||||
for (const span of [0.06, 0.204, 1, 30.07, 800]) {
|
const jupiter = bodyMarkerRadiusAu(69911);
|
||||||
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeLessThan(span / 5);
|
const ganymede = bodyMarkerRadiusAu(2634);
|
||||||
}
|
const callisto = bodyMarkerRadiusAu(2410);
|
||||||
|
const GANYMEDE_SEMI_MAJOR_AXIS_AU = 0.007155;
|
||||||
|
|
||||||
|
expect(ganymede).toBeLessThan(jupiter);
|
||||||
|
expect(callisto).toBeLessThan(ganymede);
|
||||||
|
expect(jupiter + ganymede).toBeLessThan(GANYMEDE_SEMI_MAJOR_AXIS_AU);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gives compact and wide systems the same apparent marker size', () => {
|
it('keeps Phobos outside Mars, where a marker scaled to the system buried it', () => {
|
||||||
const apparent = (span: number) => bodyMarkerRadiusAu(EARTH_RADIUS_KM, span) / systemFramingDistanceAu(span);
|
const PHOBOS_SEMI_MAJOR_AXIS_AU = 0.00006268;
|
||||||
|
|
||||||
expect(apparent(0.204)).toBeCloseTo(apparent(10), 6);
|
expect(bodyMarkerRadiusAu(3390) + bodyMarkerRadiusAu(11.27)).toBeLessThan(PHOBOS_SEMI_MAJOR_AXIS_AU);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still renders a bigger body as a bigger marker', () => {
|
it('still renders a bigger body as a bigger marker', () => {
|
||||||
const jupiter = bodyMarkerRadiusAu(69911, SOLAR_SPAN_AU);
|
expect(bodyMarkerRadiusAu(69911)).toBeGreaterThan(bodyMarkerRadiusAu(1188));
|
||||||
const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU);
|
|
||||||
|
|
||||||
expect(jupiter).toBeGreaterThan(pluto);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the smallest marker for a body with no known radius', () => {
|
it('falls back to an Earth for a body with no published radius', () => {
|
||||||
const unknown = bodyMarkerRadiusAu(undefined, SOLAR_SPAN_AU);
|
for (const nothing of [undefined, 0, -1]) {
|
||||||
const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU);
|
expect(bodyMarkerRadiusAu(nothing as number | undefined)).toBeCloseTo(EARTH_RADIUS_KM / KM_PER_AU, 12);
|
||||||
|
|
||||||
expect(unknown).toBeGreaterThan(0);
|
|
||||||
expect(unknown).toBeLessThanOrEqual(pluto);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats a missing span as the reference scale rather than collapsing to zero', () => {
|
|
||||||
for (const span of [0, -5, Number.NaN]) {
|
|
||||||
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeGreaterThan(0);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves the solar system essentially as it was before scaling', () => {
|
|
||||||
// The constants were tuned at this span, so the scale factor here is ~1.
|
|
||||||
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU)).toBeCloseTo(0.09, 2);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('systemGridRingsAu', () => {
|
describe('systemGridRingsAu', () => {
|
||||||
|
|||||||
@@ -27,31 +27,6 @@ export const DEFAULT_STAR_MARKER_RADIUS_AU = 0.2;
|
|||||||
*/
|
*/
|
||||||
const STAR_RADIUS_TO_INNERMOST_ORBIT = 0.45;
|
const STAR_RADIUS_TO_INNERMOST_ORBIT = 0.45;
|
||||||
|
|
||||||
/**
|
|
||||||
* Halo extent as a multiple of the star's own radius, and the floor on that extent as a
|
|
||||||
* fraction of the framed radius.
|
|
||||||
*
|
|
||||||
* The floor is what keeps a star visible. A system's star is sized against its *innermost*
|
|
||||||
* orbit — it must never swallow its closest planet — while the camera is placed to frame the
|
|
||||||
* *outermost* ring, and those differ by a factor of a hundred in the solar system. At the
|
|
||||||
* distance that fits Pluto in view, a disc that stays clear of Mercury is about one pixel
|
|
||||||
* across; there is no radius that satisfies both, because the information genuinely does not
|
|
||||||
* fit on one screen at that zoom.
|
|
||||||
*
|
|
||||||
* The halo resolves it, because light is not a surface: a glow that reaches past the innermost
|
|
||||||
* orbit does not claim the star is that large, it claims the star is bright. So the disc stays
|
|
||||||
* honest to the orbits and the halo is floored against the frame.
|
|
||||||
*
|
|
||||||
* The floor is set by what it must not cover. Its visual radius is half the extent, so a floor
|
|
||||||
* of `f` puts the halo's edge at `f / 2` of the frame radius — and the orbits it has to leave
|
|
||||||
* legible sit at their own fraction of that same radius. In the solar system, framed to hold
|
|
||||||
* Pluto, Venus's orbit is at 1.3% of the frame radius and Earth's at 1.8%, so a floor of 2%
|
|
||||||
* leaves both of them outside the halo. Mercury's, at 0.7%, is inside it — and would be at any
|
|
||||||
* halo large enough to see, since the orbit itself is only a few pixels wide there.
|
|
||||||
*/
|
|
||||||
const STAR_GLOW_TO_MARKER = 3.2;
|
|
||||||
const MIN_STAR_GLOW_TO_FRAME = 0.02;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear space left around the framed radius, as a fraction of it. The camera backs off this
|
* Clear space left around the framed radius, as a fraction of it. The camera backs off this
|
||||||
* much further than the geometry strictly needs, so the outermost ring sits inside the frame
|
* much further than the geometry strictly needs, so the outermost ring sits inside the frame
|
||||||
@@ -150,20 +125,6 @@ export function systemFrameRadiusAu(distanceAu: number, viewport: SystemViewport
|
|||||||
return distanceAu * tightHalfExtent(viewport);
|
return distanceAu * tightHalfExtent(viewport);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extent (AU) of the star's glow sprite — how wide it is drawn, not its radius.
|
|
||||||
*
|
|
||||||
* Normally a multiple of the star's own radius, so a compact system keeps the corona it has.
|
|
||||||
* Floored against the framed radius, so a star framed from far enough out to hold its whole
|
|
||||||
* system still reads as a bright point rather than disappearing into it. `glowScale` lets a
|
|
||||||
* caller dim the halo for stars drawn without a real photograph.
|
|
||||||
*/
|
|
||||||
export function starGlowExtentAu(markerRadiusAu: number, frameRadiusAu: number, glowScale = 1): number {
|
|
||||||
const fromStar = markerRadiusAu * STAR_GLOW_TO_MARKER * glowScale;
|
|
||||||
const fromFrame = Number.isFinite(frameRadiusAu) && frameRadiusAu > 0 ? frameRadiusAu * MIN_STAR_GLOW_TO_FRAME : 0;
|
|
||||||
return Math.max(fromStar, fromFrame);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Distance (AU) to settle the camera at so that `framedRadiusAu` fits in view with a margin
|
* Distance (AU) to settle the camera at so that `framedRadiusAu` fits in view with a margin
|
||||||
* around it.
|
* around it.
|
||||||
@@ -223,32 +184,35 @@ export function systemGridRingsAu(outermostOrbitAu: number): number[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Span of the solar system, in AU, used as the reference every other system's marker sizes are
|
* A body is drawn at its true size. Astronomical Unit in kilometres, and what a body with neither
|
||||||
* scaled against. The marker constants below were tuned by eye at this scale.
|
* a radius nor a mass to estimate one from is drawn as: an Earth, for want of anything better —
|
||||||
|
* exoplanets with a mass and no radius get an estimate from their mass before they reach here.
|
||||||
*/
|
*/
|
||||||
const REFERENCE_SYSTEM_SPAN_AU = 30;
|
const KM_PER_AU = 149597870.7;
|
||||||
|
const DEFAULT_BODY_RADIUS_KM = 6371;
|
||||||
/** Exaggerated (non-physical) marker sizes at the reference scale, so planets stay visible. */
|
|
||||||
const MIN_MARKER_RADIUS_AU = 0.012;
|
|
||||||
const MAX_MARKER_RADIUS_AU = 0.09;
|
|
||||||
/** Physical radius (km) that maps to one AU of marker radius before clamping. */
|
|
||||||
const MARKER_RADIUS_KM_PER_AU = 18000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Radius (AU) to draw a planet, moon or exoplanet marker at, scaled to the system it sits in.
|
* The Sun's own radius, in AU — the one star whose size this map knows.
|
||||||
*
|
*
|
||||||
* Marker sizes are deliberately exaggerated — a true-scale Earth would be invisible next to its
|
* Every other star is drawn at {@link starMarkerRadiusAu}, a size derived from its innermost
|
||||||
* own orbit — but the exaggeration has to be relative to the system, not absolute. Fixed AU
|
* orbit rather than measured, because no stellar radius reaches the app: the catalogue carries
|
||||||
* sizes tuned against the solar system's 30 AU span become grotesque in a system a hundredth
|
* positions, magnitudes and colours. Gaia publishes `radius_gspphot` for most of what is drawn
|
||||||
* that size: a marker of 0.09 AU inside a 0.2 AU system is wider than the orbits it sits on, so
|
* here, and until the ETL fetches it, a system's star is the one body in the view that is not
|
||||||
* a single planet swallows the entire view.
|
* to scale.
|
||||||
*
|
|
||||||
* Scaling by the span keeps every system looking like the solar system does: orbits legible,
|
|
||||||
* planets as small dots on them.
|
|
||||||
*/
|
*/
|
||||||
export function bodyMarkerRadiusAu(radiusKm: number | undefined, systemSpanAu: number): number {
|
export const SUN_RADIUS_AU = 696340 / KM_PER_AU;
|
||||||
const span = Number.isFinite(systemSpanAu) && systemSpanAu > 0 ? systemSpanAu : REFERENCE_SYSTEM_SPAN_AU;
|
|
||||||
const atReferenceScale = radiusKm ? clamp(radiusKm / MARKER_RADIUS_KM_PER_AU, MIN_MARKER_RADIUS_AU, MAX_MARKER_RADIUS_AU) : MIN_MARKER_RADIUS_AU;
|
|
||||||
|
|
||||||
return atReferenceScale * (span / REFERENCE_SYSTEM_SPAN_AU);
|
/**
|
||||||
|
* Radius (AU) to draw a planet, moon or exoplanet marker at: its own, unexaggerated.
|
||||||
|
*
|
||||||
|
* Sizes used to be exaggerated and scaled to the system span, which is what made a moon the size
|
||||||
|
* of its planet — Jupiter and Ganymede both ran past the ceiling and were drawn at one radius, so
|
||||||
|
* every moon orbited inside its parent. True scale needs no rule to prevent that: physics already
|
||||||
|
* puts a moon outside the planet it orbits, and the Sun at a hundredth of Mercury’s orbit.
|
||||||
|
*
|
||||||
|
* What true scale costs is visibility at the framing that holds a whole system, where every body
|
||||||
|
* is sub-pixel. That is paid for on screen instead, in pixels, by the scene's `keepMarkersLegible`.
|
||||||
|
*/
|
||||||
|
export function bodyMarkerRadiusAu(radiusKm: number | undefined): number {
|
||||||
|
return (radiusKm && radiusKm > 0 ? radiusKm : DEFAULT_BODY_RADIUS_KM) / KM_PER_AU;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,3 +372,95 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('rotation', () => {
|
||||||
|
/** Earth, near enough: a day of 23.934 h, tipped 23.44 degrees off its orbit. */
|
||||||
|
function spinning(overrides: Partial<BodyRecord> = {}): BodyRecord {
|
||||||
|
return {
|
||||||
|
id: 'earth',
|
||||||
|
systemStarId: 0,
|
||||||
|
name: 'Earth',
|
||||||
|
kind: 'planet',
|
||||||
|
radiusKm: 6371,
|
||||||
|
orbit: { semiMajorAxisAu: 1, eccentricity: 0.0167, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD },
|
||||||
|
rotationPeriodHours: 23.934,
|
||||||
|
obliquityDeg: 23.4392911,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far the marker has turned about its own axis between two dates, in degrees. */
|
||||||
|
function turnedDegrees(body: BodyRecord, afterDays: number): number {
|
||||||
|
const renderer = new SystemOrbitsRenderer([body], [], undefined, 1);
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD);
|
||||||
|
const start = renderer.members[0].marker.quaternion.clone();
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD + afterDays);
|
||||||
|
const turn = start.invert().multiply(renderer.members[0].marker.quaternion);
|
||||||
|
const axis = new THREE.Vector3();
|
||||||
|
const angle = 2 * Math.acos(Math.min(1, Math.abs(turn.w)));
|
||||||
|
turn.normalize();
|
||||||
|
axis.set(turn.x, turn.y, turn.z);
|
||||||
|
const signed = axis.y >= 0 ? angle : -angle;
|
||||||
|
return (signed * 180) / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('turns a body once per its own sidereal day', () => {
|
||||||
|
// A full turn in 23.934 h, so a quarter of that is a quarter turn.
|
||||||
|
expect(Math.abs(turnedDegrees(spinning(), 23.934 / 96))).toBeCloseTo(90, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which way a body spins in the world: its angular velocity projected on its orbit's normal.
|
||||||
|
* Positive is prograde, turning the same way it goes round; negative is retrograde.
|
||||||
|
*/
|
||||||
|
function spinSense(body: BodyRecord): number {
|
||||||
|
const renderer = new SystemOrbitsRenderer([body], [], undefined, 1);
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD);
|
||||||
|
const start = renderer.members[0].marker.quaternion.clone();
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD + 0.01);
|
||||||
|
const turn = renderer.members[0].marker.quaternion.clone().multiply(start.invert());
|
||||||
|
const axis = new THREE.Vector3(turn.x, turn.y, turn.z).multiplyScalar(Math.sign(turn.w));
|
||||||
|
return axis.normalize().dot(new THREE.Vector3(0, 0, 1).applyQuaternion(renderer.referenceFrame));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('turns Venus backwards, as Horizons gives it: a negative rate and an obliquity past 90', () => {
|
||||||
|
// Both say retrograde, in two conventions. Applied together they cancelled into a forward
|
||||||
|
// turn, which is how Venus and Uranus used to be drawn.
|
||||||
|
const venus = spinning({ id: 'venus', rotationPeriodHours: -5832.54, obliquityDeg: 177.3 });
|
||||||
|
|
||||||
|
expect(spinSense(spinning())).toBeGreaterThan(0.9);
|
||||||
|
expect(spinSense(venus)).toBeLessThan(-0.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the sign of the period only where no obliquity says which way the pole points', () => {
|
||||||
|
expect(spinSense(spinning({ rotationPeriodHours: -23.934, obliquityDeg: undefined }))).toBeLessThan(-0.9);
|
||||||
|
expect(spinSense(spinning({ rotationPeriodHours: 23.934, obliquityDeg: undefined }))).toBeGreaterThan(0.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a body with no published rotation still', () => {
|
||||||
|
// Titan: Horizons states no period for it, and an invented one would be a claim.
|
||||||
|
const renderer = new SystemOrbitsRenderer([spinning({ rotationPeriodHours: undefined })], [], undefined, 1);
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD);
|
||||||
|
const start = renderer.members[0].marker.quaternion.clone();
|
||||||
|
renderer.update(DEFAULT_EPOCH_JD + 40);
|
||||||
|
|
||||||
|
expect(renderer.members[0].marker.quaternion.angleTo(start)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exoplanet size without a measured radius', () => {
|
||||||
|
const radiusOf = (overrides: Partial<ExoplanetRecord>): number => {
|
||||||
|
const renderer = new SystemOrbitsRenderer([], [exoplanet(overrides)], undefined, 1);
|
||||||
|
return ((renderer.members[0].marker as THREE.Mesh).geometry as THREE.SphereGeometry).parameters.radius;
|
||||||
|
};
|
||||||
|
const EARTH_AU = 6371 / 149597870.7;
|
||||||
|
|
||||||
|
it('draws a giant known only by its mass at about Jupiter’s size, not at an Earth', () => {
|
||||||
|
// 14 Her b: 2 829 Earth masses, no radius. It used to come out the size of the Earth.
|
||||||
|
expect(radiusOf({ radiusEarth: undefined, massEarth: 2829 }) / EARTH_AU).toBeCloseTo(11.2, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a measured radius over any estimate', () => {
|
||||||
|
expect(radiusOf({ radiusEarth: 1.88, massEarth: 2829 }) / EARTH_AU).toBeCloseTo(1.88, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import * as THREE from 'three/webgpu';
|
|||||||
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
||||||
import { gmForParent } from '../../shared/astro/constants';
|
import { gmForParent } from '../../shared/astro/constants';
|
||||||
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
|
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
|
||||||
import { MARKER_TEXTURE_HEIGHT, MARKER_TEXTURE_WIDTH, planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
||||||
|
import { bodyTexturePath, loadCachedTexture } from '../../shared/rendering/texture-catalog';
|
||||||
import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
|
import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
|
||||||
import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
||||||
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
|
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
|
||||||
@@ -18,6 +19,8 @@ export interface SystemMember {
|
|||||||
id: string;
|
id: string;
|
||||||
kind: SystemMemberKind;
|
kind: SystemMemberKind;
|
||||||
marker: THREE.Object3D;
|
marker: THREE.Object3D;
|
||||||
|
/** For a moon, the id of the body it orbits: what its drawn size is held against. */
|
||||||
|
parentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLANET_COLOR = new THREE.Color(0.55, 0.75, 1.0);
|
const PLANET_COLOR = new THREE.Color(0.55, 0.75, 1.0);
|
||||||
@@ -119,21 +122,110 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A marker sphere, surfaced with the body's own derived appearance rather than a flat category
|
* A marker sphere, surfaced with the body's own photograph where one has ever been taken, and
|
||||||
* colour — so a system reads as a set of distinct worlds at a glance, and the colour of each is
|
* with a texture derived from its measurements where none has — and lit by its star either way,
|
||||||
* a consequence of its measurements rather than of which list it came from.
|
* so a world shows the day and night it actually has.
|
||||||
*
|
*
|
||||||
* The texture is tiny (see `MARKER_TEXTURE_WIDTH`): a marker is a few pixels across, so what
|
* The photographs were already in the repository, used only by the detail page: the system view
|
||||||
* survives is essentially its average colour, and generating it costs well under a millisecond.
|
* drew every body from a 32 by 16 pixel procedural texture instead, which at a few pixels across
|
||||||
|
* was indistinguishable from its average colour and, once the camera closed in, was a blur. A
|
||||||
|
* marker can now fill the frame, so it takes the real image at the size the detail page uses.
|
||||||
*/
|
*/
|
||||||
function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined, systemSpanAu: number, appearance: PlanetAppearance | undefined): THREE.Mesh {
|
function buildMarker(id: string | undefined, kind: SystemMemberKind, radiusKm: number | undefined, appearance: PlanetAppearance | undefined): THREE.Mesh {
|
||||||
const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm, systemSpanAu), 16, 12);
|
const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm), MARKER_WIDTH_SEGMENTS, MARKER_HEIGHT_SEGMENTS);
|
||||||
const material = appearance
|
const photograph = id ? bodyTexturePath(id) : undefined;
|
||||||
? new THREE.MeshBasicMaterial({ map: planetTexture(appearance, { width: MARKER_TEXTURE_WIDTH, height: MARKER_TEXTURE_HEIGHT }) })
|
// 128 by 64 for the derived texture, not the detail page's 512 by 256: that size costs about
|
||||||
: new THREE.MeshBasicMaterial({ color: colorForKind(kind) });
|
// 60 ms a body on the main thread, 360 ms on entering a six-planet system, for a disc that is a
|
||||||
|
// few pixels across until the camera is on top of it.
|
||||||
|
const map = photograph ? loadCachedTexture(photograph) : appearance ? planetTexture(appearance, { width: 128, height: 64 }) : undefined;
|
||||||
|
const material = new THREE.MeshStandardMaterial({
|
||||||
|
map,
|
||||||
|
color: map ? 0xffffff : colorForKind(kind),
|
||||||
|
roughness: 1,
|
||||||
|
metalness: 0
|
||||||
|
});
|
||||||
return new THREE.Mesh(geometry, material);
|
return new THREE.Mesh(geometry, material);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The star's own light, at the centre of the system it lights.
|
||||||
|
*
|
||||||
|
* `decay` is 0, which is not what light does: a point source falls off with the square of the
|
||||||
|
* distance, and under that law Neptune, at 30.2 AU, receives about a six-thousandth of what
|
||||||
|
* Mercury does at 0.39 AU and reads as black. The map is a set of worlds to look at rather than a
|
||||||
|
* light meter, so each is lit as a photograph of it would be — the same concession the pixel
|
||||||
|
* floor makes for size. What the light does carry truthfully is which side is day: every body
|
||||||
|
* shows its lit face toward the star, and the terminator falls where it really falls.
|
||||||
|
*
|
||||||
|
* White, at π: a Lambertian surface returns intensity / π of its texture where the light falls
|
||||||
|
* square on it, so π gives back the photograph itself at the point facing the star, and less
|
||||||
|
* towards the limb. A warm tint or a smaller figure darkened the photographs below what they are.
|
||||||
|
*/
|
||||||
|
function starLight(): THREE.PointLight {
|
||||||
|
const light = new THREE.PointLight(0xffffff, Math.PI, 0, 0);
|
||||||
|
light.position.set(0, 0, 0);
|
||||||
|
return light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sphere segments. On a UV sphere the silhouette seen down the pole is the ring of width segments
|
||||||
|
* and the one seen from the side is the meridian profile, so height at half the width makes the
|
||||||
|
* error the same from every direction: at 64 by 32 a body filling the screen — Jupiter reaches
|
||||||
|
* 641 px of radius in the plan view — strays under a pixel from its true circle.
|
||||||
|
*/
|
||||||
|
const MARKER_WIDTH_SEGMENTS = 64;
|
||||||
|
const MARKER_HEIGHT_SEGMENTS = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A drawn radius, in Earth radii, for an exoplanet that has a mass and no measured radius — 1 076
|
||||||
|
* of the 1 692 drawn, most of them found by radial velocity, and most of those giants: their
|
||||||
|
* median is 315 Earth masses. Drawn at an Earth, as they were, a nine-Jupiter-mass planet came out
|
||||||
|
* smaller than its system's super-Earth.
|
||||||
|
*
|
||||||
|
* A rough power law, capped at Jupiter's radius: giants from a third of a Jupiter mass to ten are
|
||||||
|
* all about Jupiter's size, since past that point added mass compresses rather than inflates. It
|
||||||
|
* sets a size to draw, not a figure to print — the readout still says the radius is unknown.
|
||||||
|
*/
|
||||||
|
function radiusFromMassEarth(massEarth: number | null | undefined): number | undefined {
|
||||||
|
return massEarth && massEarth > 0 ? Math.min(JUPITER_RADIUS_EARTH, massEarth ** 0.55) : undefined;
|
||||||
|
}
|
||||||
|
const JUPITER_RADIUS_EARTH = 11.2;
|
||||||
|
|
||||||
|
/** Local axis a sphere is built around, and what the spin is applied about. */
|
||||||
|
const SPIN_AXIS = new THREE.Vector3(0, 1, 0);
|
||||||
|
const HOURS_PER_DAY = 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a body is turned at a given date: its own sidereal rotation, about its own axis.
|
||||||
|
*
|
||||||
|
* The obliquity fixes how far the pole leans from the orbit normal, and nothing more: which way
|
||||||
|
* it leans needs the pole's right ascension, which the Horizons pages this reads do not carry. The
|
||||||
|
* lean is taken about the orbit's ascending node because that is the one line the elements name,
|
||||||
|
* not because the data says so — so the tilt is real and its azimuth is not. Likewise the phase:
|
||||||
|
* each body starts at its elements' epoch (2025-01-01 here) in an arbitrary orientation, the
|
||||||
|
* shortest rotation of +Y onto its axis, and turns from there. The rate and the sense are real;
|
||||||
|
* the face towards the camera is not.
|
||||||
|
*
|
||||||
|
* Horizons states a retrograde spin twice over, in two conventions: an obliquity past 90 degrees
|
||||||
|
* (Venus 177.3, Uranus 97.8) and a negative rate. Either one alone turns the body backwards, and
|
||||||
|
* both together cancel into a forward turn — which is how Venus and Uranus were drawn. Where an
|
||||||
|
* obliquity is given it carries the sense, and the period is taken as a magnitude; the sign of the
|
||||||
|
* period is only read for a body with no obliquity at all.
|
||||||
|
*/
|
||||||
|
function spinFor(elements: OrbitalElements, frame: THREE.Quaternion, rotationPeriodHours: number, obliquityDeg: number | undefined, epochJd: number): THREE.Quaternion {
|
||||||
|
const node = elements.longitudeOfAscendingNodeDeg * DEG_TO_RAD;
|
||||||
|
const inclination = elements.inclinationDeg * DEG_TO_RAD;
|
||||||
|
const nodeDirection = new THREE.Vector3(Math.cos(node), Math.sin(node), 0);
|
||||||
|
const axis = new THREE.Vector3(Math.sin(inclination) * Math.sin(node), -Math.sin(inclination) * Math.cos(node), Math.cos(inclination))
|
||||||
|
.applyAxisAngle(nodeDirection, (obliquityDeg ?? 0) * DEG_TO_RAD)
|
||||||
|
.applyQuaternion(frame);
|
||||||
|
const period = obliquityDeg === undefined ? rotationPeriodHours : Math.abs(rotationPeriodHours);
|
||||||
|
const turns = ((epochJd - elements.epochJd) * HOURS_PER_DAY) / period;
|
||||||
|
return new THREE.Quaternion()
|
||||||
|
.setFromUnitVectors(SPIN_AXIS, axis)
|
||||||
|
.multiply(new THREE.Quaternion().setFromAxisAngle(SPIN_AXIS, turns * 2 * Math.PI));
|
||||||
|
}
|
||||||
|
|
||||||
interface TrackedTopLevelBody {
|
interface TrackedTopLevelBody {
|
||||||
id: string;
|
id: string;
|
||||||
kind: SystemMemberKind;
|
kind: SystemMemberKind;
|
||||||
@@ -144,6 +236,9 @@ interface TrackedTopLevelBody {
|
|||||||
frame: THREE.Quaternion;
|
frame: THREE.Quaternion;
|
||||||
/** AU position last computed for this body; moons read their parent's here. */
|
/** AU position last computed for this body; moons read their parent's here. */
|
||||||
position: THREE.Vector3;
|
position: THREE.Vector3;
|
||||||
|
/** Sidereal rotation, where the catalogue publishes one; negative is retrograde. */
|
||||||
|
rotationPeriodHours?: number;
|
||||||
|
obliquityDeg?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrackedMoon {
|
interface TrackedMoon {
|
||||||
@@ -154,6 +249,8 @@ interface TrackedMoon {
|
|||||||
frame: THREE.Quaternion;
|
frame: THREE.Quaternion;
|
||||||
pivot: THREE.Group;
|
pivot: THREE.Group;
|
||||||
parentId: string;
|
parentId: string;
|
||||||
|
rotationPeriodHours?: number;
|
||||||
|
obliquityDeg?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -206,8 +303,6 @@ export class SystemOrbitsRenderer {
|
|||||||
const members: SystemMember[] = [];
|
const members: SystemMember[] = [];
|
||||||
const topLevelBodiesById = new Map<string, BodyRecord>();
|
const topLevelBodiesById = new Map<string, BodyRecord>();
|
||||||
|
|
||||||
// Measured before anything is built, because marker sizes are scaled against the span and
|
|
||||||
// the markers are created as the bodies are added.
|
|
||||||
const topLevelAxes = [
|
const topLevelAxes = [
|
||||||
...bodies.filter((body) => !body.parentBodyId).map((body) => body.orbit.semiMajorAxisAu),
|
...bodies.filter((body) => !body.parentBodyId).map((body) => body.orbit.semiMajorAxisAu),
|
||||||
...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map((exoplanet) => exoplanet.orbit.semiMajorAxisAu!)
|
...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map((exoplanet) => exoplanet.orbit.semiMajorAxisAu!)
|
||||||
@@ -227,7 +322,7 @@ export class SystemOrbitsRenderer {
|
|||||||
}
|
}
|
||||||
// A body reaches here only when it has no parentBodyId, so `kind` is 'planet' or 'dwarf'.
|
// A body reaches here only when it has no parentBodyId, so `kind` is 'planet' or 'dwarf'.
|
||||||
const kind: SystemMemberKind = body.kind;
|
const kind: SystemMemberKind = body.kind;
|
||||||
const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar));
|
const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar), { periodHours: body.rotationPeriodHours, obliquityDeg: body.obliquityDeg });
|
||||||
members.push({ id: body.id, kind, marker: tracked.marker });
|
members.push({ id: body.id, kind, marker: tracked.marker });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,8 +335,8 @@ export class SystemOrbitsRenderer {
|
|||||||
if (!parentTracked) {
|
if (!parentTracked) {
|
||||||
continue; // orphaned moon reference; skip rather than crash.
|
continue; // orphaned moon reference; skip rather than crash.
|
||||||
}
|
}
|
||||||
const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar));
|
const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar), { periodHours: body.rotationPeriodHours, obliquityDeg: body.obliquityDeg });
|
||||||
members.push({ id: body.id, kind: 'moon', marker: moon.marker });
|
members.push({ id: body.id, kind: 'moon', marker: moon.marker, parentId: parent.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every exoplanet in a system shares the same line of sight, so the frame is built once.
|
// Every exoplanet in a system shares the same line of sight, so the frame is built once.
|
||||||
@@ -256,7 +351,8 @@ export class SystemOrbitsRenderer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const elements = resolveOrbitalElements(exoplanet.orbit);
|
const elements = resolveOrbitalElements(exoplanet.orbit);
|
||||||
const radiusKm = exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined;
|
const radiusEarth = exoplanet.radiusEarth ?? radiusFromMassEarth(exoplanet.massEarth);
|
||||||
|
const radiusKm = radiusEarth ? radiusEarth * EARTH_RADIUS_KM : undefined;
|
||||||
// Not `gmForParent(undefined)`: that assumes a solar-mass host for every system, and
|
// Not `gmForParent(undefined)`: that assumes a solar-mass host for every system, and
|
||||||
// most exoplanet hosts are red dwarfs a fraction of the Sun's mass.
|
// most exoplanet hosts are red dwarfs a fraction of the Sun's mass.
|
||||||
const gm = resolveGravitationalParameter({
|
const gm = resolveGravitationalParameter({
|
||||||
@@ -299,6 +395,9 @@ export class SystemOrbitsRenderer {
|
|||||||
|
|
||||||
this.object.add(this.grid.object, this.tethers.object);
|
this.object.add(this.grid.object, this.tethers.object);
|
||||||
}
|
}
|
||||||
|
// The star lights its own system. The star marker itself is unlit — it is the source, not a
|
||||||
|
// surface — so nothing here changes how it is drawn.
|
||||||
|
this.object.add(starLight());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recomputes every marker's position for the given Julian date. Call once per tick. */
|
/** Recomputes every marker's position for the given Julian date. Call once per tick. */
|
||||||
@@ -307,6 +406,9 @@ export class SystemOrbitsRenderer {
|
|||||||
const orbital = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd);
|
const orbital = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd);
|
||||||
body.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(body.frame);
|
body.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(body.frame);
|
||||||
body.marker.position.copy(body.position);
|
body.marker.position.copy(body.position);
|
||||||
|
if (body.rotationPeriodHours) {
|
||||||
|
body.marker.quaternion.copy(spinFor(body.elements, body.frame, body.rotationPeriodHours, body.obliquityDeg, epochJd));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const moon of this.moons) {
|
for (const moon of this.moons) {
|
||||||
@@ -317,6 +419,9 @@ export class SystemOrbitsRenderer {
|
|||||||
moon.pivot.position.copy(parent.position);
|
moon.pivot.position.copy(parent.position);
|
||||||
const orbital = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd);
|
const orbital = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd);
|
||||||
moon.marker.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(moon.frame);
|
moon.marker.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(moon.frame);
|
||||||
|
if (moon.rotationPeriodHours) {
|
||||||
|
moon.marker.quaternion.copy(spinFor(moon.elements, moon.frame, moon.rotationPeriodHours, moon.obliquityDeg, epochJd));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Moons are left out: their tether would land within a marker's width of their planet's and
|
// Moons are left out: their tether would land within a marker's width of their planet's and
|
||||||
@@ -371,15 +476,16 @@ export class SystemOrbitsRenderer {
|
|||||||
gmAu3PerDay2: number,
|
gmAu3PerDay2: number,
|
||||||
radiusKm: number | undefined,
|
radiusKm: number | undefined,
|
||||||
frame: THREE.Quaternion,
|
frame: THREE.Quaternion,
|
||||||
appearance?: PlanetAppearance
|
appearance?: PlanetAppearance,
|
||||||
|
rotation?: { periodHours?: number; obliquityDeg?: number }
|
||||||
): TrackedTopLevelBody {
|
): TrackedTopLevelBody {
|
||||||
const orbitLine = buildOrbitLine(elements, kind, frame);
|
const orbitLine = buildOrbitLine(elements, kind, frame);
|
||||||
const marker = buildMarker(kind, radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance);
|
const marker = buildMarker(id, kind, radiusKm, appearance);
|
||||||
this.object.add(orbitLine, marker);
|
this.object.add(orbitLine, marker);
|
||||||
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
||||||
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
||||||
|
|
||||||
const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, frame, position: new THREE.Vector3() };
|
const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, frame, position: new THREE.Vector3(), rotationPeriodHours: rotation?.periodHours, obliquityDeg: rotation?.obliquityDeg };
|
||||||
this.topLevelBodies.push(tracked);
|
this.topLevelBodies.push(tracked);
|
||||||
return tracked;
|
return tracked;
|
||||||
}
|
}
|
||||||
@@ -391,17 +497,18 @@ export class SystemOrbitsRenderer {
|
|||||||
radiusKm: number | undefined,
|
radiusKm: number | undefined,
|
||||||
parent: TrackedTopLevelBody,
|
parent: TrackedTopLevelBody,
|
||||||
frame: THREE.Quaternion,
|
frame: THREE.Quaternion,
|
||||||
appearance?: PlanetAppearance
|
appearance?: PlanetAppearance,
|
||||||
|
rotation?: { periodHours?: number; obliquityDeg?: number }
|
||||||
): TrackedMoon {
|
): TrackedMoon {
|
||||||
const pivot = new THREE.Group();
|
const pivot = new THREE.Group();
|
||||||
const orbitLine = buildOrbitLine(elements, 'moon', frame);
|
const orbitLine = buildOrbitLine(elements, 'moon', frame);
|
||||||
const marker = buildMarker('moon', radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance);
|
const marker = buildMarker(id, 'moon', radiusKm, appearance);
|
||||||
pivot.add(orbitLine, marker);
|
pivot.add(orbitLine, marker);
|
||||||
this.object.add(pivot);
|
this.object.add(pivot);
|
||||||
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
||||||
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
||||||
|
|
||||||
const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, frame, pivot, parentId: parent.id };
|
const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, frame, pivot, parentId: parent.id, rotationPeriodHours: rotation?.periodHours, obliquityDeg: rotation?.obliquityDeg };
|
||||||
this.moons.push(moon);
|
this.moons.push(moon);
|
||||||
return moon;
|
return moon;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,4 +252,118 @@ describe('HudDockComponent', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(tab('Readout').getAttribute('aria-selected')).toBe('true');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, ElementRef, HostListener, inject, input, OnInit, output, signal, viewChild } from '@angular/core';
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
ElementRef,
|
||||||
|
HostListener,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
OnInit,
|
||||||
|
output,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store';
|
import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store';
|
||||||
|
import { TIME_RATES, TimeStore } from '../../shared/state/time.store';
|
||||||
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
|
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
|
||||||
import { SearchComponent } from '../search/search.component';
|
import { SearchComponent } from '../search/search.component';
|
||||||
import { RouteRequest, RouteResult, RoutesPanelComponent, RouteStarOption } from './routes-panel.component';
|
import {
|
||||||
|
RouteRequest,
|
||||||
|
RouteResult,
|
||||||
|
RoutesPanelComponent,
|
||||||
|
RouteStarOption,
|
||||||
|
} from './routes-panel.component';
|
||||||
|
|
||||||
export interface HudReadout {
|
export interface HudReadout {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
@@ -31,7 +49,16 @@ export interface HudDisplay {
|
|||||||
readonly plan: boolean;
|
readonly plan: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true, systems: true, jumpLinks: false, plan: false };
|
export const DEFAULT_HUD_DISPLAY: HudDisplay = {
|
||||||
|
labels: true,
|
||||||
|
orbits: true,
|
||||||
|
grid: true,
|
||||||
|
deepSky: true,
|
||||||
|
sky: true,
|
||||||
|
systems: true,
|
||||||
|
jumpLinks: false,
|
||||||
|
plan: false,
|
||||||
|
};
|
||||||
|
|
||||||
const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
||||||
{ key: 'labels', label: 'Labels' },
|
{ key: 'labels', label: 'Labels' },
|
||||||
@@ -41,17 +68,26 @@ const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
|||||||
{ key: 'sky', label: 'Sky' },
|
{ key: 'sky', label: 'Sky' },
|
||||||
{ key: 'systems', label: 'Systems' },
|
{ key: 'systems', label: 'Systems' },
|
||||||
{ key: 'jumpLinks', label: 'Jump links' },
|
{ key: 'jumpLinks', label: 'Jump links' },
|
||||||
{ key: 'plan', label: 'Plan view' }
|
{ key: 'plan', label: 'Plan view' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | 'display';
|
export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | 'display';
|
||||||
|
|
||||||
const TAB_LABELS: Record<DockTab, string> = { search: 'Search', readout: 'Readout', routes: 'Routes', bookmarks: 'Bookmarks', display: 'Display' };
|
const TAB_LABELS: Record<DockTab, string> = {
|
||||||
|
search: 'Search',
|
||||||
|
readout: 'Readout',
|
||||||
|
routes: 'Routes',
|
||||||
|
bookmarks: 'Bookmarks',
|
||||||
|
display: 'Display',
|
||||||
|
};
|
||||||
|
|
||||||
/** Tailwind's `sm` breakpoint: below it the dock is a bare tab strip and its panel is a sheet. */
|
/** Tailwind's `sm` breakpoint: below it the dock is a bare tab strip and its panel is a sheet. */
|
||||||
const WIDE_VIEWPORT = '(min-width: 640px)';
|
const WIDE_VIEWPORT = '(min-width: 640px)';
|
||||||
/** One live query, read on every pointer-down, rather than a new MediaQueryList per read. */
|
/** One live query, read on every pointer-down, rather than a new MediaQueryList per read. */
|
||||||
const wideViewportQuery = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(WIDE_VIEWPORT) : null;
|
const wideViewportQuery =
|
||||||
|
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||||
|
? window.matchMedia(WIDE_VIEWPORT)
|
||||||
|
: null;
|
||||||
|
|
||||||
function isWideViewport(): boolean {
|
function isWideViewport(): boolean {
|
||||||
return wideViewportQuery?.matches ?? true;
|
return wideViewportQuery?.matches ?? true;
|
||||||
@@ -74,7 +110,10 @@ function isWideViewport(): boolean {
|
|||||||
selector: 'app-hud-dock',
|
selector: 'app-hud-dock',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [BookmarkIconComponent, RoutesPanelComponent, SearchComponent],
|
imports: [BookmarkIconComponent, RoutesPanelComponent, SearchComponent],
|
||||||
host: { class: 'pointer-events-none fixed inset-x-2 bottom-2 z-20 block font-body sm:inset-x-6 sm:bottom-6' },
|
host: {
|
||||||
|
class:
|
||||||
|
'pointer-events-none fixed inset-x-2 bottom-2 z-20 block font-body sm:inset-x-6 sm:bottom-6',
|
||||||
|
},
|
||||||
template: `
|
template: `
|
||||||
<!-- The column is transparent to the pointer and each surface in it opts back in: it is as
|
<!-- The column is transparent to the pointer and each surface in it opts back in: it is as
|
||||||
wide as the strip and as tall as the open panel, so a solid one would swallow every
|
wide as the strip and as tall as the open panel, so a solid one would swallow every
|
||||||
@@ -85,28 +124,54 @@ function isWideViewport(): boolean {
|
|||||||
locking on, once per switch, never per keystroke. -->
|
locking on, once per switch, never per keystroke. -->
|
||||||
@switch (tab) {
|
@switch (tab) {
|
||||||
@case ('search') {
|
@case ('search') {
|
||||||
<section id="dock-panel-search" role="tabpanel" aria-labelledby="dock-tab-search" class="hud-acquire pointer-events-auto mb-2 w-full max-w-xl">
|
<section
|
||||||
|
id="dock-panel-search"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="dock-tab-search"
|
||||||
|
class="hud-acquire pointer-events-auto mb-2 w-full max-w-xl"
|
||||||
|
>
|
||||||
<app-search (picked)="onPicked()" />
|
<app-search (picked)="onPicked()" />
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@case ('readout') {
|
@case ('readout') {
|
||||||
<section id="dock-panel-readout" role="tabpanel" aria-labelledby="dock-tab-readout" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3">
|
<section
|
||||||
|
id="dock-panel-readout"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="dock-tab-readout"
|
||||||
|
class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3"
|
||||||
|
>
|
||||||
<p class="type-label text-muted">{{ eyebrow() }}</p>
|
<p class="type-label text-muted">{{ eyebrow() }}</p>
|
||||||
<div class="mt-1 flex items-start gap-2">
|
<div class="mt-1 flex items-start gap-2">
|
||||||
<p data-testid="hud-title" class="min-w-0 flex-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
|
<p
|
||||||
|
data-testid="hud-title"
|
||||||
|
class="min-w-0 flex-1 text-lg font-bold tracking-[0.04em] text-text uppercase"
|
||||||
|
>
|
||||||
|
{{ title() }}
|
||||||
|
</p>
|
||||||
<!-- Against null, not against falsiness: the Sun's catalogue id is 0, and a
|
<!-- Against null, not against falsiness: the Sun's catalogue id is 0, and a
|
||||||
truthiness test is what would quietly make the Solar System the one
|
truthiness test is what would quietly make the Solar System the one
|
||||||
system nobody could keep. -->
|
system nobody could keep. -->
|
||||||
@if (keepableStarId() !== null) {
|
@if (keepableStarId() !== null) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
[attr.aria-label]="(bookmarks.has('star', keepableStarId()!) ? 'Forget ' : 'Keep ') + title()"
|
[attr.aria-label]="
|
||||||
|
(bookmarks.has('star', keepableStarId()!) ? 'Forget ' : 'Keep ') + title()
|
||||||
|
"
|
||||||
[attr.aria-pressed]="bookmarks.has('star', keepableStarId()!)"
|
[attr.aria-pressed]="bookmarks.has('star', keepableStarId()!)"
|
||||||
(click)="bookmarks.toggle({ kind: 'star', id: keepableStarId()!, name: title() })"
|
(click)="
|
||||||
|
bookmarks.toggle({ kind: 'star', id: keepableStarId()!, name: title() })
|
||||||
|
"
|
||||||
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
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'"
|
[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()!)" />
|
<app-bookmark-icon
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
[kept]="bookmarks.has('star', keepableStarId()!)"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -117,43 +182,54 @@ function isWideViewport(): boolean {
|
|||||||
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
||||||
@for (readout of readouts(); track readout.label) {
|
@for (readout of readouts(); track readout.label) {
|
||||||
<div>
|
<div>
|
||||||
<dt class="type-label text-muted">{{ readout.label }}@if (readout.derived) {<span class="text-accent/80" aria-hidden="true">*</span>}</dt>
|
<dt class="type-label text-muted">
|
||||||
|
{{ readout.label }}
|
||||||
|
@if (readout.derived) {
|
||||||
|
<span class="text-accent/80" aria-hidden="true">*</span>
|
||||||
|
}
|
||||||
|
</dt>
|
||||||
<dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
<dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
}
|
}
|
||||||
@if (note() || hasDerived()) {
|
@if (note() || hasDerived()) {
|
||||||
<p class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted">@if (hasDerived()) {<span class="text-accent/80">*</span> Derived, not catalogued. }{{ note() }}</p>
|
<p
|
||||||
|
class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted"
|
||||||
|
>
|
||||||
|
@if (hasDerived()) {
|
||||||
|
<span class="text-accent/80">*</span> Derived, not catalogued.
|
||||||
}
|
}
|
||||||
</section>
|
{{ note() }}
|
||||||
|
</p>
|
||||||
}
|
}
|
||||||
@case ('routes') {
|
|
||||||
<section id="dock-panel-routes" role="tabpanel" aria-labelledby="dock-tab-routes" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-xl px-4 py-3">
|
|
||||||
<app-routes-panel
|
|
||||||
[result]="routeResult()"
|
|
||||||
[options]="routeOptions()"
|
|
||||||
[currentStar]="currentStar()"
|
|
||||||
(queryChange)="routeQuery.emit($event)"
|
|
||||||
(routeRequested)="routeRequested.emit($event)"
|
|
||||||
(starSelected)="onRouteStarSelected($event)"
|
|
||||||
(rangeChange)="jumpRangeChange.emit($event)"
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@case ('bookmarks') {
|
@case ('bookmarks') {
|
||||||
<section id="dock-panel-bookmarks" role="tabpanel" aria-labelledby="dock-tab-bookmarks" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg">
|
<section
|
||||||
|
id="dock-panel-bookmarks"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="dock-tab-bookmarks"
|
||||||
|
class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg"
|
||||||
|
>
|
||||||
@if (bookmarks.bookmarks().length) {
|
@if (bookmarks.bookmarks().length) {
|
||||||
<ul class="max-h-64 divide-y divide-border/25 overflow-y-auto">
|
<ul class="max-h-64 divide-y divide-border/25 overflow-y-auto">
|
||||||
@for (bookmark of bookmarks.bookmarks(); track bookmark.kind + ':' + bookmark.id) {
|
@for (
|
||||||
|
bookmark of bookmarks.bookmarks();
|
||||||
|
track bookmark.kind + ':' + bookmark.id
|
||||||
|
) {
|
||||||
<li class="flex items-stretch">
|
<li class="flex items-stretch">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="onBookmarkChosen(bookmark)"
|
(click)="onBookmarkChosen(bookmark)"
|
||||||
class="flex min-w-0 flex-1 items-baseline gap-3 px-3 py-2 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
class="flex min-w-0 flex-1 items-baseline gap-3 px-3 py-2 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
>
|
>
|
||||||
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ bookmark.name }}</span>
|
<span class="min-w-0 flex-1 truncate text-sm text-text">{{
|
||||||
<span class="type-label shrink-0 text-muted">{{ bookmark.kind === 'star' ? 'System' : 'Body' }}</span>
|
bookmark.name
|
||||||
|
}}</span>
|
||||||
|
<span class="type-label shrink-0 text-muted">{{
|
||||||
|
bookmark.kind === 'star' ? 'System' : 'Body'
|
||||||
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -161,7 +237,15 @@ function isWideViewport(): boolean {
|
|||||||
(click)="bookmarks.remove(bookmark.kind, bookmark.id)"
|
(click)="bookmarks.remove(bookmark.kind, bookmark.id)"
|
||||||
class="shrink-0 border-l border-border/25 px-3 text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
class="shrink-0 border-l border-border/25 px-3 text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
>
|
>
|
||||||
<svg class="h-3 w-3" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">
|
<svg
|
||||||
|
class="h-3 w-3"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<path d="M3 3l8 8M11 3l-8 8" />
|
<path d="M3 3l8 8M11 3l-8 8" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -170,13 +254,20 @@ function isWideViewport(): boolean {
|
|||||||
</ul>
|
</ul>
|
||||||
} @else {
|
} @else {
|
||||||
<p class="px-3 py-3 text-sm text-muted">
|
<p class="px-3 py-3 text-sm text-muted">
|
||||||
Nothing kept yet. The <app-bookmark-icon class="inline-block h-3.5 w-3.5 -mb-0.5 text-accent" /> on a readout or a body keeps it here, in this browser.
|
Nothing kept yet. The
|
||||||
|
<app-bookmark-icon class="inline-block h-3.5 w-3.5 -mb-0.5 text-accent" /> on a
|
||||||
|
readout or a body keeps it here, in this browser.
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@case ('display') {
|
@case ('display') {
|
||||||
<section id="dock-panel-display" role="tabpanel" aria-labelledby="dock-tab-display" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3">
|
<section
|
||||||
|
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>
|
<p class="type-label text-muted">Layers</p>
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
@for (layer of layers; track layer.key) {
|
@for (layer of layers; track layer.key) {
|
||||||
@@ -185,18 +276,92 @@ function isWideViewport(): boolean {
|
|||||||
[attr.aria-pressed]="isOn(layer.key)"
|
[attr.aria-pressed]="isOn(layer.key)"
|
||||||
(click)="toggleLayer(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="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'"
|
[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. -->
|
<!-- 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>
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-1.5 w-1.5 border border-current"
|
||||||
|
[class.bg-current]="isOn(layer.key)"
|
||||||
|
></span>
|
||||||
{{ layer.label }}
|
{{ layer.label }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- The clock. Orbits and rotations are both functions of a date, so this is the
|
||||||
|
difference between a still picture and an orrery. -->
|
||||||
|
<p class="type-label mt-4 text-muted">Clock</p>
|
||||||
|
<!-- Radios rather than buttons: the rates are one-of-four, and the native control
|
||||||
|
carries that to a screen reader and to the arrow keys without any script. -->
|
||||||
|
<div
|
||||||
|
class="mt-2 flex flex-wrap items-center gap-2"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Clock rate"
|
||||||
|
>
|
||||||
|
@for (rate of timeRates; track rate.secondsPerSecond) {
|
||||||
|
<label
|
||||||
|
class="type-label cursor-pointer border px-3 py-1.5 transition-colors has-[:focus-visible]:outline has-[:focus-visible]:outline-1 has-[:focus-visible]:-outline-offset-1 has-[:focus-visible]:outline-accent"
|
||||||
|
[class]="
|
||||||
|
time.rate() === rate.secondsPerSecond
|
||||||
|
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
||||||
|
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="clock-rate"
|
||||||
|
class="sr-only"
|
||||||
|
[value]="rate.secondsPerSecond"
|
||||||
|
[checked]="time.rate() === rate.secondsPerSecond"
|
||||||
|
(change)="time.setRate(rate.secondsPerSecond)"
|
||||||
|
/>
|
||||||
|
{{ rate.label }}
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
@if (!time.atNow()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="time.reset()"
|
||||||
|
class="type-label border border-border/60 px-3 py-1.5 text-muted transition-colors hover:border-accent/70 hover:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
Back to now
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</section>
|
</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 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">
|
<div role="tablist" aria-label="Dock" class="flex items-stretch divide-x divide-border/40">
|
||||||
@@ -209,21 +374,39 @@ function isWideViewport(): boolean {
|
|||||||
[attr.aria-controls]="activeTab() === tab ? 'dock-panel-' + tab : null"
|
[attr.aria-controls]="activeTab() === tab ? 'dock-panel-' + tab : null"
|
||||||
(click)="toggleTab(tab)"
|
(click)="toggleTab(tab)"
|
||||||
class="type-eyebrow px-3 py-2 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent sm:px-4"
|
class="type-eyebrow px-3 py-2 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent sm:px-4"
|
||||||
[class]="activeTab() === tab ? 'bg-accent/15 text-accent' : 'text-muted hover:bg-accent/8 hover:text-accent'"
|
[class]="
|
||||||
|
activeTab() === tab
|
||||||
|
? 'bg-accent/15 text-accent'
|
||||||
|
: 'text-muted hover:bg-accent/8 hover:text-accent'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ tabLabel(tab) }}
|
{{ tabLabel(tab) }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Only while the map is away from the present: at the present the date is
|
||||||
|
today's, which the reader's own machine already says. -->
|
||||||
|
@if (date()) {
|
||||||
|
<p
|
||||||
|
class="ml-auto flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4"
|
||||||
|
data-testid="hud-date"
|
||||||
|
>
|
||||||
|
<span class="type-label text-muted">Date</span>
|
||||||
|
<span class="text-sm text-accent tabular-nums">{{ date() }}</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
@if (range()) {
|
@if (range()) {
|
||||||
<p class="ml-auto flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4">
|
<p
|
||||||
|
class="flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4"
|
||||||
|
[class.ml-auto]="!date()"
|
||||||
|
>
|
||||||
<span class="type-label text-muted">Range</span>
|
<span class="type-label text-muted">Range</span>
|
||||||
<span class="text-sm text-accent tabular-nums">{{ range() }}</span>
|
<span class="text-sm text-accent tabular-nums">{{ range() }}</span>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`,
|
||||||
})
|
})
|
||||||
export class HudDockComponent implements OnInit {
|
export class HudDockComponent implements OnInit {
|
||||||
/** Readout panel contents. An empty title means there is nothing to read out, and no tab for it. */
|
/** Readout panel contents. An empty title means there is nothing to read out, and no tab for it. */
|
||||||
@@ -235,12 +418,15 @@ export class HudDockComponent implements OnInit {
|
|||||||
readonly note = input('');
|
readonly note = input('');
|
||||||
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
|
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
|
||||||
readonly range = input('');
|
readonly range = input('');
|
||||||
|
/** The date the sky is drawn for; empty while the map is drawn for the present. */
|
||||||
|
readonly date = input('');
|
||||||
/** Layer state; `null` means the surface has no layers to toggle and no Display tab. */
|
/** Layer state; `null` means the surface has no layers to toggle and no Display tab. */
|
||||||
readonly display = input<HudDisplay | null>(null);
|
readonly display = input<HudDisplay | null>(null);
|
||||||
/** Which panel is open on a wide viewport when the dock mounts. */
|
/** Which panel is open on a wide viewport when the dock mounts. */
|
||||||
readonly defaultTab = input<DockTab | null>(null);
|
readonly defaultTab = input<DockTab | null>(null);
|
||||||
/** Routing: what the scene found, what it offers for the fields, and where the view is. */
|
/** Routing: what the scene found, what it offers for the fields, and where the view is. */
|
||||||
readonly routeResult = input<RouteResult | null>(null);
|
readonly routeResult = input<RouteResult | null>(null);
|
||||||
|
readonly routePending = input(false);
|
||||||
readonly routeOptions = input<readonly RouteStarOption[]>([]);
|
readonly routeOptions = input<readonly RouteStarOption[]>([]);
|
||||||
readonly currentStar = input<RouteStarOption | null>(null);
|
readonly currentStar = input<RouteStarOption | null>(null);
|
||||||
/** The star the readout is about, where there is one to keep — a scale is not a place. */
|
/** The star the readout is about, where there is one to keep — a scale is not a place. */
|
||||||
@@ -264,12 +450,14 @@ export class HudDockComponent implements OnInit {
|
|||||||
// Always offered, even with nothing in it: it is the only place that says the map can keep
|
// Always offered, even with nothing in it: it is the only place that says the map can keep
|
||||||
// anything at all, and a tab that appears once you already know is a tab that never taught.
|
// anything at all, and a tab that appears once you already know is a tab that never taught.
|
||||||
'bookmarks',
|
'bookmarks',
|
||||||
...(this.display() ? (['display'] as const) : [])
|
...(this.display() ? (['display'] as const) : []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
readonly activeTab = signal<DockTab | null>(null);
|
readonly activeTab = signal<DockTab | null>(null);
|
||||||
|
|
||||||
readonly bookmarks = inject(BookmarksStore);
|
readonly bookmarks = inject(BookmarksStore);
|
||||||
|
readonly time = inject(TimeStore);
|
||||||
|
readonly timeRates = TIME_RATES;
|
||||||
|
|
||||||
private readonly search = viewChild(SearchComponent);
|
private readonly search = viewChild(SearchComponent);
|
||||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
@@ -324,7 +512,10 @@ export class HudDockComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = event.target as HTMLElement | null;
|
const target = event.target as HTMLElement | null;
|
||||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
if (
|
||||||
|
target &&
|
||||||
|
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -336,7 +527,11 @@ export class HudDockComponent implements OnInit {
|
|||||||
/** On a narrow viewport the panel is a sheet over the scene: tapping the scene folds it away. */
|
/** On a narrow viewport the panel is a sheet over the scene: tapping the scene folds it away. */
|
||||||
@HostListener('document:pointerdown', ['$event'])
|
@HostListener('document:pointerdown', ['$event'])
|
||||||
onDocumentPointerDown(event: PointerEvent): void {
|
onDocumentPointerDown(event: PointerEvent): void {
|
||||||
if (this.activeTab() && !isWideViewport() && !this.host.nativeElement.contains(event.target as Node)) {
|
if (
|
||||||
|
this.activeTab() &&
|
||||||
|
!isWideViewport() &&
|
||||||
|
!this.host.nativeElement.contains(event.target as Node)
|
||||||
|
) {
|
||||||
this.activeTab.set(null);
|
this.activeTab.set(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { ChangeDetectionStrategy, Component, computed, input, output, signal } f
|
|||||||
|
|
||||||
import { formatParsecs } from '../../shared/format/quantity';
|
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. */
|
/** A star offered for one of the two fields, as the panel needs to show it. */
|
||||||
export interface RouteStarOption {
|
export interface RouteStarOption {
|
||||||
readonly id: number;
|
readonly id: number;
|
||||||
@@ -14,8 +20,12 @@ export interface RouteResult {
|
|||||||
/** The chain, departure first. Empty when there is no route at the range asked for. */
|
/** The chain, departure first. Empty when there is no route at the range asked for. */
|
||||||
readonly stars: readonly { id: number; name: string }[];
|
readonly stars: readonly { id: number; name: string }[];
|
||||||
readonly totalPc: number;
|
readonly totalPc: number;
|
||||||
/** The shortest range that would open a route, where none was found at the one asked for. */
|
/** A range that would open a route, where none was found at the one asked for. */
|
||||||
readonly neededRangePc: number | null;
|
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 {
|
export interface RouteRequest {
|
||||||
@@ -94,11 +104,11 @@ type Field = 'from' | 'to';
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!canPlot()"
|
[disabled]="!canPlot() || pending()"
|
||||||
(click)="plot()"
|
(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"
|
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"
|
||||||
>
|
>
|
||||||
Plot route
|
{{ pending() ? 'Plotting…' : 'Plot route' }}
|
||||||
</button>
|
</button>
|
||||||
@if (result(); as plotted) {
|
@if (result(); as plotted) {
|
||||||
@if (plotted.stars.length) {
|
@if (plotted.stars.length) {
|
||||||
@@ -107,17 +117,22 @@ type Field = 'from' | 'to';
|
|||||||
</p>
|
</p>
|
||||||
} @else {
|
} @else {
|
||||||
<p data-testid="route-summary" class="text-sm text-muted">
|
<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.
|
No route at this range.
|
||||||
|
}
|
||||||
@if (plotted.neededRangePc !== null) {
|
@if (plotted.neededRangePc !== null) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
[disabled]="!canPlot() || pending()"
|
||||||
(click)="raiseTo(plotted.neededRangePc)"
|
(click)="raiseTo(plotted.neededRangePc)"
|
||||||
class="text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent"
|
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.
|
{{ format(plotted.neededRangePc) }} would reach.
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else if (plotted.least) {
|
||||||
Nothing in the catalogue bridges the gap.
|
No chain of jumps up to {{ format(maxRangePc) }} reaches it.
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
@@ -148,6 +163,8 @@ export class RoutesPanelComponent {
|
|||||||
readonly result = input<RouteResult | null>(null);
|
readonly result = input<RouteResult | null>(null);
|
||||||
/** Matches for the field currently being typed into, ranked by the scene. */
|
/** Matches for the field currently being typed into, ranked by the scene. */
|
||||||
readonly options = input<readonly RouteStarOption[]>([]);
|
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. */
|
/** The star the view is currently inside, offered as the departure without typing. */
|
||||||
readonly currentStar = input<RouteStarOption | null>(null);
|
readonly currentStar = input<RouteStarOption | null>(null);
|
||||||
|
|
||||||
@@ -160,8 +177,7 @@ export class RoutesPanelComponent {
|
|||||||
readonly fields: readonly Field[] = ['from', 'to'];
|
readonly fields: readonly Field[] = ['from', 'to'];
|
||||||
/** A tenth of a parsec is finer than the catalogue's own distances are known to. */
|
/** A tenth of a parsec is finer than the catalogue's own distances are known to. */
|
||||||
readonly minRangePc = 0.5;
|
readonly minRangePc = 0.5;
|
||||||
/** Beyond this the graph is a solid sheet of lines and every pair of stars is connected. */
|
readonly maxRangePc = MAX_JUMP_RANGE_PC;
|
||||||
readonly maxRangePc = 8;
|
|
||||||
|
|
||||||
readonly rangePc = signal(3);
|
readonly rangePc = signal(3);
|
||||||
readonly open = signal<Field | null>(null);
|
readonly open = signal<Field | null>(null);
|
||||||
@@ -169,8 +185,14 @@ export class RoutesPanelComponent {
|
|||||||
private readonly chosen = signal<Record<Field, RouteStarOption | null>>({ from: null, to: null });
|
private readonly chosen = signal<Record<Field, RouteStarOption | null>>({ from: null, to: null });
|
||||||
private readonly typed = signal<Record<Field, string>>({ from: '', to: '' });
|
private readonly typed = signal<Record<Field, string>>({ from: '', to: '' });
|
||||||
|
|
||||||
/** Departure falls back to wherever the view already is, so one field is usually enough. */
|
/**
|
||||||
private readonly departure = computed(() => this.chosen().from ?? this.currentStar());
|
* 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 canPlot = computed(() => this.departure() !== null && this.chosen().to !== null);
|
||||||
readonly rangeLabel = computed(() => formatParsecs(this.rangePc()));
|
readonly rangeLabel = computed(() => formatParsecs(this.rangePc()));
|
||||||
|
|||||||
@@ -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,
|
OBLIQUITY_J2000_DEG,
|
||||||
parallaxMasToParsecs,
|
parallaxMasToParsecs,
|
||||||
parseSexagesimal,
|
parseSexagesimal,
|
||||||
|
propagateProperMotion,
|
||||||
raDecDistanceToXyz,
|
raDecDistanceToXyz,
|
||||||
raDecToUnitVector,
|
raDecToUnitVector,
|
||||||
raDegDecDistanceToXyz
|
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', () => {
|
describe('parallaxMasToParsecs', () => {
|
||||||
it('converts a positive parallax to the expected distance', () => {
|
it('converts a positive parallax to the expected distance', () => {
|
||||||
expect(parallaxMasToParsecs(769.33)).toBeCloseTo(1.3, 2); // Proxima Centauri
|
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);
|
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
|
* 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.
|
* its equator, and so the angle between this app's two source frames.
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { buildStarNameIndex, normalizeStarName, resolveHostStarId } from './host-star-matching';
|
import { buildStarNameIndex, normalizeStarName, resolveHostStarId } from './host-star-matching';
|
||||||
|
import { propagateProperMotion, raDegDecDistanceToXyz } from './coordinates';
|
||||||
import { StarRecord } from '../models/star.model';
|
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.
|
// exoplanet host-star cross-referencing logic without hitting any real API.
|
||||||
const FIXTURE_STARS: StarRecord[] = [
|
const FIXTURE_STARS: StarRecord[] = [
|
||||||
// The Sun sits at the origin, exactly where a host with a missing distance lands.
|
// The Sun sits at the origin, exactly where a host with a missing distance lands.
|
||||||
@@ -22,46 +27,150 @@ describe('normalizeStarName', () => {
|
|||||||
|
|
||||||
describe('resolveHostStarId', () => {
|
describe('resolveHostStarId', () => {
|
||||||
it('matches by exact (normalized) host star name', () => {
|
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);
|
expect(id).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matches by name regardless of case/spacing differences', () => {
|
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);
|
expect(id).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to nearest-neighbour position matching when the name is unknown', () => {
|
it('returns null when there is no name match and no position is available', () => {
|
||||||
// Slightly off from Sirius's exact position, within tolerance.
|
const id = resolveHostStarId({ hostname: 'Unknown Star XYZ', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS);
|
||||||
const id = resolveHostStarId({ hostname: 'Sirius A', raDeg: 101.29, decDeg: -16.72, distancePc: 2.64 }, FIXTURE_STARS, 0.5);
|
|
||||||
|
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);
|
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', () => {
|
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
|
// the Sun at distance 0. That shipped 127 alien planets, all seven TRAPPIST-1 worlds among
|
||||||
// them, into our own solar system.
|
// them, into our own solar system.
|
||||||
it('does not match a host with a zero distance to the Sun', () => {
|
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();
|
expect(id).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a negative distance too', () => {
|
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();
|
expect(id).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still matches a real host at a genuinely small distance', () => {
|
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);
|
expect(id).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lets a named host resolve even with no usable distance', () => {
|
it('lets a named host resolve even with no usable distance', () => {
|
||||||
const id = resolveHostStarId({ hostname: 'Sirius', raDeg: 101.3, decDeg: -16.7, distancePc: 0 }, FIXTURE_STARS, 0.5);
|
const id = resolveHostStarId({ hostname: 'Sirius', raDeg: 101.3, decDeg: -16.7, distancePc: 0 }, FIXTURE_STARS);
|
||||||
|
|
||||||
expect(id).toBe(2);
|
expect(id).toBe(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CartesianCoordinates, distanceBetween, raDegDecDistanceToXyz } from './coordinates';
|
import { propagateProperMotion, raDegDecDistanceToXyz } from './coordinates';
|
||||||
import { ExoplanetRecord } from '../models/exoplanet.model';
|
import { MERGE_DISTANCE_RATIO_TOLERANCE } from './star-merge';
|
||||||
import { StarRecord } from '../models/star.model';
|
import { StarRecord } from '../models/star.model';
|
||||||
|
|
||||||
/** Normalizes a star name for comparison: lowercase, alphanumeric characters only. */
|
/** Normalizes a star name for comparison: lowercase, alphanumeric characters only. */
|
||||||
@@ -12,25 +12,92 @@ export interface HostStarQuery {
|
|||||||
raDeg: number;
|
raDeg: number;
|
||||||
decDeg: number;
|
decDeg: number;
|
||||||
distancePc: number;
|
distancePc: number;
|
||||||
}
|
/** μα·cos δ in mas/yr, as the archive publishes it (`sy_pmra`); missing means unknown. */
|
||||||
|
pmRaMasPerYear?: number;
|
||||||
/** Builds a lookup of normalized star name -> star, for fast repeated name matching. */
|
pmDecMasPerYear?: number;
|
||||||
export function buildStarNameIndex(stars: readonly StarRecord[]): Map<string, StarRecord> {
|
|
||||||
return new Map(stars.map((star) => [normalizeStarName(star.name), star]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cross-references an exoplanet host star to the HYG star index: first by (normalized)
|
* Builds a lookup of normalized star name -> star, for fast repeated name matching.
|
||||||
* name, then by nearest-neighbour position matching within `toleranceInPc`. Returns `null`
|
*
|
||||||
|
* 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.
|
* 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
|
* `nameIndex` should be built once (via {@link buildStarNameIndex}) and reused across calls
|
||||||
* when resolving many queries against the same star list.
|
* when resolving many queries against the same star list.
|
||||||
*/
|
*/
|
||||||
export function resolveHostStarId(
|
export function resolveHostStarId(
|
||||||
query: HostStarQuery,
|
query: HostStarQuery,
|
||||||
stars: readonly StarRecord[],
|
stars: readonly StarRecord[],
|
||||||
toleranceInPc: number,
|
|
||||||
nameIndex: Map<string, StarRecord> = buildStarNameIndex(stars)
|
nameIndex: Map<string, StarRecord> = buildStarNameIndex(stars)
|
||||||
): number | null {
|
): number | null {
|
||||||
const byName = nameIndex.get(normalizeStarName(query.hostname));
|
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
|
// 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
|
// missing CSV cell takes: `Number('')` is `0`. Without a believable distance there is no
|
||||||
// then places the host exactly at the origin — where it matches the Sun at distance 0 and
|
// transverse budget and no ratio test, so the position cannot speak.
|
||||||
// hands an alien planet to our own solar system.
|
|
||||||
if (query.distancePc <= 0) {
|
if (query.distancePc <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostPosition = raDegDecDistanceToXyz(query.raDeg, query.decDeg, query.distancePc);
|
const published = raDegDecDistanceToXyz(query.raDeg, query.decDeg, 1);
|
||||||
return findNearestStarWithin(hostPosition, stars, toleranceInPc);
|
const carriedBack = propagateProperMotion(
|
||||||
}
|
query.raDeg,
|
||||||
|
query.decDeg,
|
||||||
function findNearestStarWithin(position: CartesianCoordinates, stars: readonly StarRecord[], toleranceInPc: number): number | null {
|
// A proper motion that is not a number must read as "stands still", not poison the
|
||||||
let closest: { id: number; distance: number } | null = null;
|
// 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.
|
||||||
for (const star of stars) {
|
knownMotion(query.pmRaMasPerYear),
|
||||||
const distance = distanceBetween(position, star);
|
knownMotion(query.pmDecMasPerYear),
|
||||||
if (distance <= toleranceInPc && (!closest || distance < closest.distance)) {
|
CATALOGUE_EPOCH - ARCHIVE_LATEST_EPOCH
|
||||||
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 carried = raDegDecDistanceToXyz(carriedBack.raDeg, carriedBack.decDeg, 1);
|
||||||
|
|
||||||
exoplanet.hostStarId = positioned ? resolved : (resolved ?? previous);
|
const minCosine = Math.cos(Math.min(Math.PI, HOST_TRANSVERSE_TOLERANCE_PC / query.distancePc));
|
||||||
if (exoplanet.hostStarId !== null) {
|
let best: StarRecord | null = null;
|
||||||
summary.matched++;
|
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) {
|
const cosine =
|
||||||
summary.gained++;
|
Math.max(
|
||||||
} else if (previous !== null && exoplanet.hostStarId === null) {
|
star.x * published.x + star.y * published.y + star.z * published.z,
|
||||||
summary.lost++;
|
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { collectJumpLinks, minimumRangeBetween, routeBetween } from './jump-links';
|
import { jumpLinkSegments, minimumRangeBetween, routeBetween } from './jump-links';
|
||||||
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
|
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
|
||||||
|
|
||||||
/** Stars a parsec apart along x, so a chain's length is the number of hops it takes. */
|
/** Stars a parsec apart along x, so a chain's length is the number of hops it takes. */
|
||||||
@@ -14,7 +14,7 @@ function index(points: StarPoint[]): StarNeighbourhood {
|
|||||||
|
|
||||||
describe('routeBetween', () => {
|
describe('routeBetween', () => {
|
||||||
it('walks the chain a hop at a time when that is all the range allows', () => {
|
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);
|
const { route } = routeBetween(chain(5), 0, 4, 1.5);
|
||||||
|
|
||||||
expect(route?.stars).toEqual([0, 1, 2, 3, 4]);
|
expect(route?.stars).toEqual([0, 1, 2, 3, 4]);
|
||||||
expect(route?.totalPc).toBeCloseTo(4);
|
expect(route?.totalPc).toBeCloseTo(4);
|
||||||
@@ -24,7 +24,7 @@ describe('routeBetween', () => {
|
|||||||
it('goes straight there when the range reaches, however many stars lie between', () => {
|
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
|
// 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.
|
// range that covers it makes it the answer, and the stars in between are just scenery.
|
||||||
const route = routeBetween(chain(5), 0, 4, 5);
|
const { route } = routeBetween(chain(5), 0, 4, 5);
|
||||||
|
|
||||||
expect(route?.stars).toEqual([0, 4]);
|
expect(route?.stars).toEqual([0, 4]);
|
||||||
expect(route?.totalPc).toBeCloseTo(4);
|
expect(route?.totalPc).toBeCloseTo(4);
|
||||||
@@ -33,7 +33,7 @@ describe('routeBetween', () => {
|
|||||||
it('picks the shorter of two ways round when neither is a straight line', () => {
|
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
|
// 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.
|
// off the line, or through 2, well off it. Shorter is what "the way there" means.
|
||||||
const route = routeBetween(
|
const { route } = routeBetween(
|
||||||
index([
|
index([
|
||||||
{ id: 0, x: 0, y: 0, z: 0 },
|
{ id: 0, x: 0, y: 0, z: 0 },
|
||||||
{ id: 1, x: 5, y: 0.5, z: 0 },
|
{ id: 1, x: 5, y: 0.5, z: 0 },
|
||||||
@@ -56,19 +56,19 @@ describe('routeBetween', () => {
|
|||||||
{ id: 2, x: 20, y: 0, z: 0 }
|
{ id: 2, x: 20, y: 0, z: 0 }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(routeBetween(split, 0, 2, 5)).toBeNull();
|
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', () => {
|
it('answers nothing for a star that is not there, or for going nowhere', () => {
|
||||||
const line = chain(3);
|
const line = chain(3);
|
||||||
|
|
||||||
expect(routeBetween(line, 0, 0, 2)).toBeNull();
|
expect(routeBetween(line, 0, 0, 2).route).toBeNull();
|
||||||
expect(routeBetween(line, 0, 99, 2)).toBeNull();
|
expect(routeBetween(line, 0, 99, 2).route).toBeNull();
|
||||||
expect(routeBetween(line, 0, 2, 0)).toBeNull();
|
expect(routeBetween(line, 0, 2, 0).route).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports the longest hop, which is what the range has to cover', () => {
|
it('reports the longest hop, which is what the range has to cover', () => {
|
||||||
const route = routeBetween(
|
const { route } = routeBetween(
|
||||||
index([
|
index([
|
||||||
{ id: 0, x: 0, y: 0, z: 0 },
|
{ id: 0, x: 0, y: 0, z: 0 },
|
||||||
{ id: 1, x: 1, y: 0, z: 0 },
|
{ id: 1, x: 1, y: 0, z: 0 },
|
||||||
@@ -81,9 +81,110 @@ describe('routeBetween', () => {
|
|||||||
|
|
||||||
expect(route?.longestHopPc).toBeCloseTo(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', () => {
|
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', () => {
|
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.
|
// Hops of 1 and 4: no range under 4 connects them, and 4 exactly does.
|
||||||
const stepped = index([
|
const stepped = index([
|
||||||
@@ -92,9 +193,9 @@ describe('minimumRangeBetween', () => {
|
|||||||
{ id: 2, x: 5, y: 0, z: 0 }
|
{ id: 2, x: 5, y: 0, z: 0 }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(minimumRangeBetween(stepped, 0, 2, 50)).toBeCloseTo(4);
|
expect(minimumRangeBetween(stepped, 0, 2, 50)).toEqual({ rangePc: expect.closeTo(4) as number, least: true });
|
||||||
expect(routeBetween(stepped, 0, 2, 4)).not.toBeNull();
|
expect(routeBetween(stepped, 0, 2, 4).route).not.toBeNull();
|
||||||
expect(routeBetween(stepped, 0, 2, 3.99)).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', () => {
|
it('prefers a longer way whose worst hop is shorter, since that is what the range pays for', () => {
|
||||||
@@ -108,8 +209,16 @@ describe('minimumRangeBetween', () => {
|
|||||||
|
|
||||||
const needed = minimumRangeBetween(both, 0, 3, 50);
|
const needed = minimumRangeBetween(both, 0, 3, 50);
|
||||||
|
|
||||||
expect(needed).toBeLessThan(10);
|
expect(needed.rangePc).toBeLessThan(10);
|
||||||
expect(routeBetween(both, 0, 3, needed!)).not.toBeNull();
|
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', () => {
|
it('finds nothing when even the ceiling does not reach', () => {
|
||||||
@@ -118,29 +227,138 @@ describe('minimumRangeBetween', () => {
|
|||||||
{ id: 1, x: 100, y: 0, z: 0 }
|
{ id: 1, x: 100, y: 0, z: 0 }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(minimumRangeBetween(split, 0, 1, 50)).toBeNull();
|
expect(minimumRangeBetween(split, 0, 1, 50)).toEqual({ rangePc: null, least: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('collectJumpLinks', () => {
|
/**
|
||||||
it('reports each pair once, not once from either end', () => {
|
* The links a segment buffer draws, as unordered pairs of star ids, read back from where each end
|
||||||
const links = collectJumpLinks(chain(4), 1.5);
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
expect(links.map((link) => [link.from, link.to])).toEqual([
|
/**
|
||||||
[0, 1],
|
* What a budget should keep, worked out the slow way: every link sorted by how near its nearer end
|
||||||
[1, 2],
|
* is to the centre, then taken until one does not fit. Lengths and distances as the float32 buffer
|
||||||
[2, 3]
|
* 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('measures every link it reports', () => {
|
it('puts both ends of every link where its stars are', () => {
|
||||||
const links = collectJumpLinks(chain(3), 2.5);
|
const segments = jumpLinkSegments(chain(3), 2.5);
|
||||||
|
|
||||||
expect(links.find((link) => link.from === 0 && link.to === 2)?.distancePc).toBeCloseTo(2);
|
expect(segments).toHaveLength(3 * 6);
|
||||||
|
expect(linksDrawn(segments, chainPoints(3)).sort()).toEqual(['0-1', '0-2', '1-2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('draws nothing at no range', () => {
|
it('draws nothing at no range', () => {
|
||||||
expect(collectJumpLinks(chain(4), 0)).toEqual([]);
|
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', () => {
|
it('agrees with every route it makes possible', () => {
|
||||||
@@ -154,10 +372,9 @@ describe('collectJumpLinks', () => {
|
|||||||
// earlier version of this test hid by only checking the route it happened to find.
|
// earlier version of this test hid by only checking the route it happened to find.
|
||||||
const range = 9;
|
const range = 9;
|
||||||
|
|
||||||
const links = collectJumpLinks(cloud, range);
|
const drawn = new Set(linksDrawn(jumpLinkSegments(cloud, range), points));
|
||||||
const drawn = new Set(links.map((link) => `${link.from}-${link.to}`));
|
|
||||||
|
|
||||||
const route = routeBetween(cloud, 0, 119, range);
|
const { route } = routeBetween(cloud, 0, 119, range);
|
||||||
// Asserted, not guarded: a skipped body would let the two disagree unnoticed.
|
// Asserted, not guarded: a skipped body would let the two disagree unnoticed.
|
||||||
expect(route).not.toBeNull();
|
expect(route).not.toBeNull();
|
||||||
expect(route!.stars.length).toBeGreaterThan(2);
|
expect(route!.stars.length).toBeGreaterThan(2);
|
||||||
@@ -165,6 +382,6 @@ describe('collectJumpLinks', () => {
|
|||||||
const [a, b] = [route!.stars[i - 1], route!.stars[i]].sort((x, y) => x - y);
|
const [a, b] = [route!.stars[i - 1], route!.stars[i]].sort((x, y) => x - y);
|
||||||
expect(drawn.has(`${a}-${b}`)).toBe(true);
|
expect(drawn.has(`${a}-${b}`)).toBe(true);
|
||||||
}
|
}
|
||||||
expect(links.length).toBeGreaterThan(0);
|
expect(drawn.size).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+303
-116
@@ -16,6 +16,21 @@
|
|||||||
|
|
||||||
import { StarNeighbourhood } from './star-neighbourhood';
|
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. */
|
/** A chain of stars from one to another, each hop within the range that was asked for. */
|
||||||
export interface Route {
|
export interface Route {
|
||||||
/** Star ids, departure first and destination last. One hop is two ids. */
|
/** Star ids, departure first and destination last. One hop is two ids. */
|
||||||
@@ -29,38 +44,96 @@ export interface Route {
|
|||||||
readonly longestHopPc: number;
|
readonly longestHopPc: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An unordered pair of stars within range of each other. */
|
/**
|
||||||
export interface JumpLink {
|
* A cap on how much of the catalogue one search may walk, so a hopeless question cannot run for
|
||||||
readonly from: number;
|
* ever. It is a budget, not a verdict: a search that spends it has proved nothing, and says so
|
||||||
readonly to: number;
|
* through {@link RouteSearch.gaveUp}.
|
||||||
readonly distancePc: number;
|
*
|
||||||
}
|
* 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A cap on how much of the catalogue one search may walk. Reached only where a route does not
|
* How close to the true minimum `minimumRangeBetween` works a range out: half the Routes panel's
|
||||||
* exist and the range is wide enough to make most of the catalogue one component; a search that
|
* own step, which it rounds up to. Never at the cost of an answer that fails to open a route,
|
||||||
* hits it has already visited more stars than any real chain passes through.
|
* since the figure it reports is always the longest hop of a route actually found.
|
||||||
*/
|
*/
|
||||||
const MAX_VISITED = 20000;
|
const RANGE_RESOLUTION_PC = 0.05;
|
||||||
|
|
||||||
/** Pops the smallest-cost entry. A linear scan: the frontier is small next to the work per node. */
|
/**
|
||||||
function takeCheapest<T>(frontier: Map<number, T>, costOf: (value: T) => number): [number, T] | undefined {
|
* How many of `minimumRangeBetween`'s probes may give up, once it has a range of its own, before it
|
||||||
let bestId: number | undefined;
|
* answers with what it has — and how many before it has one.
|
||||||
let bestValue: T | undefined;
|
*
|
||||||
let bestCost = Number.POSITIVE_INFINITY;
|
* A probe that finds a route is quick — it heads straight for the destination — while one that
|
||||||
for (const [id, value] of frontier) {
|
* gives up walks the whole search budget, about two seconds on the real catalogue. Those are also
|
||||||
const cost = costOf(value);
|
* the probes that buy the least: they cannot rule anything out. Two of them is the difference
|
||||||
if (cost < bestCost) {
|
* between an answer of 7.96 pc in half a second and 5.76 pc in seventeen, for a star at 236 pc; it
|
||||||
bestCost = cost;
|
* lands on 5.97 pc in five.
|
||||||
bestId = id;
|
*
|
||||||
bestValue = value;
|
* 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;
|
||||||
}
|
}
|
||||||
if (bestId === undefined || bestValue === undefined) {
|
this.ids[at] = this.ids[parent];
|
||||||
return undefined;
|
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;
|
||||||
}
|
}
|
||||||
frontier.delete(bestId);
|
|
||||||
return [bestId, bestValue];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebuild(cameFrom: Map<number, number>, fromId: number, toId: number): number[] {
|
function rebuild(cameFrom: Map<number, number>, fromId: number, toId: number): number[] {
|
||||||
@@ -78,129 +151,243 @@ function rebuild(cameFrom: Map<number, number>, fromId: number, toId: number): n
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shortest chain from one star to another in which no single hop exceeds `rangePc`, or
|
* The shortest chain from one star to another in which no single hop exceeds `rangePc`, or no
|
||||||
* `null` where the catalogue holds no such chain.
|
* 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
|
* 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
|
* 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
|
* "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.
|
* 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): Route | null {
|
export function routeBetween(index: StarNeighbourhood, fromId: number, toId: number, rangePc: number): RouteSearch {
|
||||||
if (fromId === toId || rangePc <= 0 || !index.point(fromId) || !index.point(toId)) {
|
const origin = index.point(fromId);
|
||||||
return null;
|
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 best = new Map<number, number>([[fromId, 0]]);
|
const travelled = new Map<number, number>([[fromId, 0]]);
|
||||||
const cameFrom = new Map<number, number>();
|
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 settled = new Set<number>();
|
||||||
const frontier = new Map<number, number>([[fromId, 0]]);
|
const frontier = new Frontier();
|
||||||
|
frontier.push(fromId, straightLineOn(origin.x, origin.y, origin.z));
|
||||||
|
|
||||||
while (frontier.size > 0 && settled.size < MAX_VISITED) {
|
let gaveUp = false;
|
||||||
const cheapest = takeCheapest(frontier, (cost) => cost);
|
while (frontier.size > 0) {
|
||||||
if (!cheapest) {
|
const starId = frontier.pop();
|
||||||
break;
|
|
||||||
}
|
|
||||||
const [starId, costHere] = cheapest;
|
|
||||||
if (settled.has(starId)) {
|
if (settled.has(starId)) {
|
||||||
continue;
|
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);
|
settled.add(starId);
|
||||||
|
const costHere = travelled.get(starId)!;
|
||||||
|
|
||||||
if (starId === toId) {
|
if (starId === toId) {
|
||||||
const stars = rebuild(cameFrom, fromId, toId);
|
const stars = rebuild(cameFrom, fromId, toId);
|
||||||
return stars.length === 0 ? null : { stars, totalPc: costHere, longestHopPc: longestHop(index, stars) };
|
if (stars.length === 0) {
|
||||||
|
return { route: null, gaveUp: false };
|
||||||
}
|
}
|
||||||
|
let longestHopPc = 0;
|
||||||
for (const neighbour of index.within(starId, rangePc)) {
|
|
||||||
if (settled.has(neighbour.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const cost = costHere + neighbour.distancePc;
|
|
||||||
if (cost < (best.get(neighbour.id) ?? Number.POSITIVE_INFINITY)) {
|
|
||||||
best.set(neighbour.id, cost);
|
|
||||||
cameFrom.set(neighbour.id, starId);
|
|
||||||
frontier.set(neighbour.id, cost);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function longestHop(index: StarNeighbourhood, stars: readonly number[]): number {
|
|
||||||
let longest = 0;
|
|
||||||
for (let i = 1; i < stars.length; i++) {
|
for (let i = 1; i < stars.length; i++) {
|
||||||
const a = index.point(stars[i - 1]);
|
longestHopPc = Math.max(longestHopPc, hopTo.get(stars[i])!);
|
||||||
const b = index.point(stars[i]);
|
|
||||||
if (a && b) {
|
|
||||||
longest = Math.max(longest, Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z));
|
|
||||||
}
|
}
|
||||||
|
return { route: { stars, totalPc: costHere, longestHopPc }, gaveUp: false };
|
||||||
}
|
}
|
||||||
return longest;
|
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shortest range at which any chain at all exists between two stars, or `null` if none does
|
* A range at which a chain exists between two stars — the shortest, to within
|
||||||
* within `ceilingPc`.
|
* `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
|
* This is what turns "no route" from a dead end into an answer: the range control can be told what
|
||||||
* what it would have to be raised to. It is the minimax path — the chain whose longest hop is as
|
* it would have to be raised to. The figure aimed at is the minimax path, the chain whose longest
|
||||||
* short as possible — found by the same search as above, with the cost of reaching a star being
|
* hop is as short as possible. It used to be searched for directly, widening from the departure in
|
||||||
* the longest hop taken to get there rather than the sum of them.
|
* 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): number | null {
|
export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, toId: number, ceilingPc: number): RangeSearch {
|
||||||
if (fromId === toId || ceilingPc <= 0 || !index.point(fromId) || !index.point(toId)) {
|
const widest = routeBetween(index, fromId, toId, ceilingPc);
|
||||||
return null;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
const best = new Map<number, number>([[fromId, 0]]);
|
// Each link's nearer end's distance from the centre, and its length.
|
||||||
const settled = new Set<number>();
|
const { centre } = budget;
|
||||||
const frontier = new Map<number, number>([[fromId, 0]]);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
while (frontier.size > 0 && settled.size < MAX_VISITED) {
|
// Nearest first, without sorting them all: every link in the bands before the one where the budget
|
||||||
const cheapest = takeCheapest(frontier, (cost) => cost);
|
// runs out fits, and only that band's links are sorted to see how many of them do. Sorting all
|
||||||
if (!cheapest) {
|
// 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;
|
break;
|
||||||
}
|
}
|
||||||
const [starId, worstHopHere] = cheapest;
|
keptPc += lengths[link];
|
||||||
if (settled.has(starId)) {
|
keptLinks.push(link);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
settled.add(starId);
|
|
||||||
|
|
||||||
if (starId === toId) {
|
|
||||||
return worstHopHere;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const neighbour of index.within(starId, ceilingPc)) {
|
const kept = new Float32Array(keptLinks.length * 6);
|
||||||
if (settled.has(neighbour.id)) {
|
keptLinks.forEach((link, at) => kept.set(vertices.subarray(link * 6, link * 6 + 6), at * 6));
|
||||||
continue;
|
return kept;
|
||||||
}
|
|
||||||
// What this chain would need: the longest hop on it, not the distance covered by it.
|
|
||||||
const needed = Math.max(worstHopHere, neighbour.distancePc);
|
|
||||||
if (needed < (best.get(neighbour.id) ?? Number.POSITIVE_INFINITY)) {
|
|
||||||
best.set(neighbour.id, needed);
|
|
||||||
frontier.set(neighbour.id, needed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every link within `rangePc` in the whole catalogue, each pair once.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export function collectJumpLinks(index: StarNeighbourhood, rangePc: number): JumpLink[] {
|
|
||||||
const links: JumpLink[] = [];
|
|
||||||
index.forEachPairWithin(rangePc, (a, b, distancePc) => {
|
|
||||||
// The smaller id first, always. The grid hands pairs over in whatever order it walks its
|
|
||||||
// cells, and a link that is `3-7` here and `7-3` there is two links to anything comparing.
|
|
||||||
links.push(a.id < b.id ? { from: a.id, to: b.id, distancePc } : { from: b.id, to: a.id, distancePc });
|
|
||||||
});
|
|
||||||
return links;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { raDegDecDistanceToXyz } from './coordinates';
|
||||||
import { StarRecord } from '../models/star.model';
|
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. */
|
/** A star at a given sky position and distance, which is how catalogues actually report them. */
|
||||||
function at(id: number, raDeg: number, decDeg: number, distancePc: number, overrides: Partial<StarRecord> = {}): StarRecord {
|
function at(id: number, raDeg: number, decDeg: number, distancePc: number, overrides: Partial<StarRecord> = {}): StarRecord {
|
||||||
@@ -13,24 +13,70 @@ function at(id: number, raDeg: number, decDeg: number, distancePc: number, overr
|
|||||||
const HIPPARCOS = { sourceId: 'hyg', parallaxPrecisionMas: 1 };
|
const HIPPARCOS = { sourceId: 'hyg', parallaxPrecisionMas: 1 };
|
||||||
const GAIA = { sourceId: 'gaia', parallaxPrecisionMas: 0.02 };
|
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', () => {
|
describe('isSameStar', () => {
|
||||||
it('matches two catalogues reporting the same star', () => {
|
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);
|
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', () => {
|
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
|
// 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.
|
// 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);
|
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', () => {
|
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);
|
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', () => {
|
it('takes two entries within three arcseconds for one star, whatever their distances say', () => {
|
||||||
// Same direction, one three times further away: a background star, not the same object.
|
// HD 225021: 143.7 pc by its Hipparcos parallax, 239.4 by Gaia's, 0.01″ apart; HIP 82724:
|
||||||
expect(isSameStar(at(1, 200, 10, 100), at(2, 200, 10, 300))).toBe(false);
|
// 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', () => {
|
it('matches on direction rather than on 3D proximity', () => {
|
||||||
@@ -72,6 +118,44 @@ describe('mergeStarCatalogues', () => {
|
|||||||
expect(summary.duplicates).toBe(1);
|
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', () => {
|
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
|
// 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.
|
// everything, and a bright star it omits should not vanish from the map.
|
||||||
@@ -123,6 +207,13 @@ describe('mergeStarCatalogues', () => {
|
|||||||
]);
|
]);
|
||||||
expect(stars).toHaveLength(1);
|
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', () => {
|
it('handles a single catalogue as a plain pass-through', () => {
|
||||||
@@ -146,3 +237,35 @@ describe('mergeStarCatalogues', () => {
|
|||||||
expect(Date.now() - started).toBeLessThan(10000);
|
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';
|
import { StarRecord } from '../models/star.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,8 +18,47 @@ import { StarRecord } from '../models/star.model';
|
|||||||
|
|
||||||
const DEG_TO_RAD = Math.PI / 180;
|
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
|
* 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;
|
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 {
|
export interface MergeCandidate {
|
||||||
readonly sourceId: string;
|
readonly sourceId: string;
|
||||||
/** Lower is better — the parallax precision this source measures with, in milliarcseconds. */
|
/** Lower is better — the parallax precision this source measures with, in milliarcseconds. */
|
||||||
@@ -36,7 +98,7 @@ export interface MergeCandidate {
|
|||||||
|
|
||||||
export interface MergeSummary {
|
export interface MergeSummary {
|
||||||
readonly total: number;
|
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 duplicates: number;
|
||||||
readonly bySource: Readonly<Record<string, number>>;
|
readonly bySource: Readonly<Record<string, number>>;
|
||||||
}
|
}
|
||||||
@@ -60,8 +122,13 @@ function distanceOf(star: StarRecord): number {
|
|||||||
*/
|
*/
|
||||||
const SKY_CELL_DEG = 0.5;
|
const SKY_CELL_DEG = 0.5;
|
||||||
|
|
||||||
|
const RA_CELLS = 360 / SKY_CELL_DEG;
|
||||||
|
|
||||||
function cellKey(raDeg: number, decDeg: number): string {
|
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 } {
|
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));
|
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 {
|
* Whether `entry` describes the star already `kept`: the same direction, the brightness not in
|
||||||
const [near, far] = [distanceOf(a), distanceOf(b)].sort((p, q) => p - q);
|
* 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 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
|
// 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;
|
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) {
|
if (separationDeg > MERGE_ANGULAR_TOLERANCE_DEG) {
|
||||||
return false;
|
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;
|
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.
|
* 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
|
* Sources are taken in order of how precisely they measure parallax, best first. An entry that a
|
||||||
* only added if no better-measured catalogue already has it. So where Gaia and Hipparcos
|
* better-measured catalogue already has is folded into that entry — the nearest one within the
|
||||||
* overlap, the position is Gaia's; where only Hipparcos reaches, the star is still there.
|
* 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 } {
|
export function mergeStarCatalogues(candidates: readonly MergeCandidate[]): { stars: StarRecord[]; summary: MergeSummary } {
|
||||||
const ordered = [...candidates].sort((a, b) => a.parallaxPrecisionMas - b.parallaxPrecisionMas);
|
const ordered = [...candidates].sort((a, b) => a.parallaxPrecisionMas - b.parallaxPrecisionMas);
|
||||||
const merged: StarRecord[] = [];
|
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> = {};
|
const bySource: Record<string, number> = {};
|
||||||
let duplicates = 0;
|
let duplicates = 0;
|
||||||
|
|
||||||
@@ -123,24 +222,41 @@ export function mergeStarCatalogues(candidates: readonly MergeCandidate[]): { st
|
|||||||
bySource[candidate.sourceId] = 0;
|
bySource[candidate.sourceId] = 0;
|
||||||
|
|
||||||
for (const star of candidate.stars) {
|
for (const star of candidate.stars) {
|
||||||
const { raDeg, decDeg } = skyAngles(star);
|
const entry: StarRecord = { ...star, source: star.source ?? candidate.sourceId };
|
||||||
const alreadyPresent = neighbouringCells(raDeg, decDeg).some((key) => (grid.get(key) ?? []).some((existing) => isSameStar(existing, star)));
|
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++;
|
duplicates++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const withSource: StarRecord = { ...star, source: star.source ?? candidate.sourceId };
|
const index = merged.push(entry) - 1;
|
||||||
merged.push(withSource);
|
|
||||||
bySource[candidate.sourceId]++;
|
bySource[candidate.sourceId]++;
|
||||||
|
|
||||||
const key = cellKey(raDeg, decDeg);
|
const key = cellKey(raDeg, decDeg);
|
||||||
const cell = grid.get(key);
|
const cell = grid.get(key);
|
||||||
if (cell) {
|
if (cell) {
|
||||||
cell.push(withSource);
|
cell.push(index);
|
||||||
} else {
|
} else {
|
||||||
grid.set(key, [withSource]);
|
grid.set(key, [index]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,4 +115,49 @@ describe('StarNeighbourhood', () => {
|
|||||||
|
|
||||||
expect(ids(index.nearest(1, 2)).sort()).toEqual([2, 3]);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,12 +38,27 @@ 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. */
|
/** Grows the search a shell of cells at a time; the cap stops a query in empty space forever. */
|
||||||
const MAX_RING = 12;
|
const MAX_RING = 12;
|
||||||
|
|
||||||
function cellKey(ix: number, iy: number, iz: number): string {
|
/**
|
||||||
return `${ix},${iy},${iz}`;
|
* 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 {
|
export class StarNeighbourhood {
|
||||||
private readonly cells = new Map<string, number[]>();
|
private readonly cells = new Map<number, number[]>();
|
||||||
private readonly points: readonly StarPoint[];
|
private readonly points: readonly StarPoint[];
|
||||||
private readonly indexById = new Map<number, number>();
|
private readonly indexById = new Map<number, number>();
|
||||||
private readonly cellSizePc: number;
|
private readonly cellSizePc: number;
|
||||||
@@ -64,6 +79,16 @@ export class StarNeighbourhood {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
/** The star this id names, or `undefined` — the caller's id may not be in the catalogue. */
|
||||||
point(id: number): StarPoint | undefined {
|
point(id: number): StarPoint | undefined {
|
||||||
const index = this.indexById.get(id);
|
const index = this.indexById.get(id);
|
||||||
@@ -135,34 +160,52 @@ export class StarNeighbourhood {
|
|||||||
* a jump-link graph is built from: one call per node gives that node's edges.
|
* a jump-link graph is built from: one call per node gives that node's edges.
|
||||||
*/
|
*/
|
||||||
within(id: number, radiusPc: number): Neighbour[] {
|
within(id: number, radiusPc: number): Neighbour[] {
|
||||||
const origin = this.point(id);
|
const found: Neighbour[] = [];
|
||||||
if (!origin || radiusPc <= 0) {
|
this.forEachWithin(id, radiusPc, (neighbour, distancePc) => found.push({ id: neighbour.id, distancePc }));
|
||||||
return [];
|
found.sort((a, b) => a.distancePc - b.distancePc);
|
||||||
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
const found: Neighbour[] = [];
|
/**
|
||||||
|
* 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 [ox, oy, oz] = this.cellFor(origin.x, origin.y, origin.z);
|
||||||
const reach = Math.ceil(radiusPc / this.cellSizePc);
|
const reach = Math.ceil(radiusPc / this.cellSizePc);
|
||||||
|
|
||||||
for (let ix = ox - reach; ix <= ox + reach; ix++) {
|
for (let ix = ox - reach; ix <= ox + reach; ix++) {
|
||||||
for (let iy = oy - reach; iy <= oy + reach; iy++) {
|
for (let iy = oy - reach; iy <= oy + reach; iy++) {
|
||||||
for (let iz = oz - reach; iz <= oz + reach; iz++) {
|
for (let iz = oz - reach; iz <= oz + reach; iz++) {
|
||||||
for (const index of this.cells.get(cellKey(ix, iy, iz)) ?? []) {
|
const cell = this.cells.get(cellKey(ix, iy, iz));
|
||||||
|
if (!cell) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const index of cell) {
|
||||||
const candidate = this.points[index];
|
const candidate = this.points[index];
|
||||||
if (candidate.id === id) {
|
if (candidate.id === id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const distancePc = Math.hypot(candidate.x - origin.x, candidate.y - origin.y, candidate.z - origin.z);
|
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) {
|
if (distancePc <= radiusPc) {
|
||||||
found.push({ id: candidate.id, distancePc });
|
visit(candidate, distancePc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
found.sort((a, b) => a.distancePc - b.distancePc);
|
|
||||||
return found;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,10 +224,9 @@ export class StarNeighbourhood {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const reach = Math.ceil(radiusPc / this.cellSizePc);
|
const reach = Math.ceil(radiusPc / this.cellSizePc);
|
||||||
const radiusSq = radiusPc * radiusPc;
|
|
||||||
|
|
||||||
for (const [key, cell] of this.cells) {
|
for (const [key, cell] of this.cells) {
|
||||||
const [ix, iy, iz] = key.split(',').map(Number);
|
const [ix, iy, iz] = cellIndices(key);
|
||||||
for (let dx = 0; dx <= reach; dx++) {
|
for (let dx = 0; dx <= reach; dx++) {
|
||||||
for (let dy = dx === 0 ? 0 : -reach; dy <= reach; dy++) {
|
for (let dy = dx === 0 ? 0 : -reach; dy <= reach; dy++) {
|
||||||
for (let dz = dx === 0 && dy === 0 ? 0 : -reach; dz <= reach; dz++) {
|
for (let dz = dx === 0 && dy === 0 ? 0 : -reach; dz <= reach; dz++) {
|
||||||
@@ -202,9 +244,9 @@ export class StarNeighbourhood {
|
|||||||
const dxp = b.x - a.x;
|
const dxp = b.x - a.x;
|
||||||
const dyp = b.y - a.y;
|
const dyp = b.y - a.y;
|
||||||
const dzp = b.z - a.z;
|
const dzp = b.z - a.z;
|
||||||
const distanceSq = dxp * dxp + dyp * dyp + dzp * dzp;
|
const distancePc = Math.sqrt(dxp * dxp + dyp * dyp + dzp * dzp);
|
||||||
if (distanceSq <= radiusSq) {
|
if (distancePc <= radiusPc) {
|
||||||
visit(a, b, Math.sqrt(distanceSq));
|
visit(a, b, distancePc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,7 +256,7 @@ export class StarNeighbourhood {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private keyFor(x: number, y: number, z: number): string {
|
private keyFor(x: number, y: number, z: number): number {
|
||||||
const [ix, iy, iz] = this.cellFor(x, y, z);
|
const [ix, iy, iz] = this.cellFor(x, y, z);
|
||||||
return cellKey(ix, iy, iz);
|
return 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)));
|
||||||
|
}
|
||||||
@@ -29,4 +29,14 @@ export interface BodyRecord {
|
|||||||
* relative to that planet, not heliocentrically. Undefined for planets/dwarfs.
|
* relative to that planet, not heliocentrically. Undefined for planets/dwarfs.
|
||||||
*/
|
*/
|
||||||
parentBodyId?: string;
|
parentBodyId?: string;
|
||||||
|
/**
|
||||||
|
* How the body turns on its own axis: the sidereal rotation period in hours, negative where
|
||||||
|
* Horizons gives a negative rate (Venus, Uranus), and the tilt of that axis from its orbital
|
||||||
|
* plane — which past 90 degrees already says the turn is retrograde.
|
||||||
|
*
|
||||||
|
* Absent where Horizons publishes neither — the view then leaves the body still rather than
|
||||||
|
* spinning it at an invented rate.
|
||||||
|
*/
|
||||||
|
rotationPeriodHours?: number;
|
||||||
|
obliquityDeg?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,17 +21,19 @@ export interface ExoplanetRecord {
|
|||||||
/** Host star mass in solar masses (`st_mass`); the fallback when no period is published. */
|
/** Host star mass in solar masses (`st_mass`); the fallback when no period is published. */
|
||||||
hostStarMassSolar?: number;
|
hostStarMassSolar?: number;
|
||||||
/**
|
/**
|
||||||
* The host star's own published position (`ra`, `dec`, `sy_dist`) — the coordinates the
|
* The host star's own published astrometry (`ra`, `dec`, `sy_dist`, `sy_pmra`, `sy_pmdec`) —
|
||||||
* cross-reference above is resolved from.
|
* everything the cross-reference above was resolved from.
|
||||||
*
|
*
|
||||||
* Kept rather than consumed and discarded. `hostStarId` is the *result* of a match against
|
* 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
|
* whatever star catalogue was loaded at the time; keeping the inputs makes auditing or
|
||||||
* some of the 4347 hosts that currently resolve to nothing — but with only the result stored,
|
* redoing that match a local operation instead of a TAP query against an archive that is not
|
||||||
* redoing the match meant re-downloading the archive. These three numbers make it a local
|
* always reachable — it is how the matcher's tolerances were measured. See
|
||||||
* operation. See `rematchHostStars`.
|
* `resolveHostStarId`.
|
||||||
*/
|
*/
|
||||||
hostRaDeg?: number;
|
hostRaDeg?: number;
|
||||||
hostDecDeg?: number;
|
hostDecDeg?: number;
|
||||||
hostDistancePc?: number;
|
hostDistancePc?: number;
|
||||||
|
hostPmRaMasPerYear?: number;
|
||||||
|
hostPmDecMasPerYear?: number;
|
||||||
orbit: Partial<OrbitalElements>;
|
orbit: Partial<OrbitalElements>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,21 @@ import * as THREE from 'three/webgpu';
|
|||||||
* Real NASA/ESA/USGS photography baked into `src/assets/textures/bodies/` at build time,
|
* Real NASA/ESA/USGS photography baked into `src/assets/textures/bodies/` at build time,
|
||||||
* keyed by the same ids used in `bodies.json`.
|
* keyed by the same ids used in `bodies.json`.
|
||||||
*
|
*
|
||||||
* This map is the whole of what has actually been photographed. Everything else — every
|
* Only surface *maps* belong here: equirectangular images, twice as wide as tall, that wrap a
|
||||||
* exoplanet, since not one has ever been imaged, and the moons no probe returned a usable map
|
* sphere. Everything else — every exoplanet, since not one has ever been imaged, and every moon
|
||||||
* of — falls through to `procedural-planet-texture.ts`, which derives a surface from the body's
|
* or dwarf planet with no such map in the repository — falls through to
|
||||||
* own measured size, mass, orbit and host star instead.
|
* `procedural-planet-texture.ts`, which derives a surface from the body's own measured size,
|
||||||
|
* mass, orbit and host star instead.
|
||||||
*
|
*
|
||||||
* Provenance (all public domain NASA/JPL or CC BY 4.0 Solar System Scope, via Wikimedia
|
* Io, Pluto, Titan and Deimos used to be listed with the square photographs of them in
|
||||||
* Commons — see each file's Commons page for the original credit line):
|
* `assets/textures/bodies/`: pictures of a lit disc against black sky, not maps. Wrapped round a
|
||||||
* mercury/venus/earth/mars/saturn/uranus/neptune/moon/sun/saturn-ring/skybox — Solar System
|
* sphere they put black sky on a fifth to a third of the surface, in a band up to 57 degrees wide
|
||||||
* Scope texture pack (CC BY 4.0); jupiter — Solar System Scope 8k pack (CC BY 4.0); pluto —
|
* across the equator that the spin then swept past the camera. They are left out until a real map
|
||||||
* NASA/JHUAPL/SwRI New Horizons true-color mosaic; deimos — NASA/JPL/University of Arizona
|
* of each is added.
|
||||||
* MRO HiRISE; io — NASA/JPL Galileo highest-resolution true-color mosaic; titan — NASA/JPL
|
*
|
||||||
* Cassini true-color view.
|
* Provenance (CC BY 4.0 Solar System Scope, via Wikimedia Commons — see each file's Commons page
|
||||||
|
* for the original credit line): mercury/venus/earth/mars/saturn/uranus/neptune/moon/sun/
|
||||||
|
* saturn-ring/skybox — Solar System Scope texture pack; jupiter — Solar System Scope 8k pack.
|
||||||
*/
|
*/
|
||||||
const BODY_TEXTURE_PATHS: Record<string, string> = {
|
const BODY_TEXTURE_PATHS: Record<string, string> = {
|
||||||
mercury: 'assets/textures/bodies/mercury.jpg',
|
mercury: 'assets/textures/bodies/mercury.jpg',
|
||||||
@@ -26,11 +29,7 @@ const BODY_TEXTURE_PATHS: Record<string, string> = {
|
|||||||
saturn: 'assets/textures/bodies/saturn.jpg',
|
saturn: 'assets/textures/bodies/saturn.jpg',
|
||||||
uranus: 'assets/textures/bodies/uranus.jpg',
|
uranus: 'assets/textures/bodies/uranus.jpg',
|
||||||
neptune: 'assets/textures/bodies/neptune.jpg',
|
neptune: 'assets/textures/bodies/neptune.jpg',
|
||||||
pluto: 'assets/textures/bodies/pluto.jpg',
|
moon: 'assets/textures/bodies/moon.jpg'
|
||||||
moon: 'assets/textures/bodies/moon.jpg',
|
|
||||||
deimos: 'assets/textures/bodies/deimos.jpg',
|
|
||||||
io: 'assets/textures/bodies/io.jpg',
|
|
||||||
titan: 'assets/textures/bodies/titan.jpg'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The Sun isn't a `BodyRecord` (it's the system's star marker), so it's looked up separately. */
|
/** The Sun isn't a `BodyRecord` (it's the system's star marker), so it's looked up separately. */
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { TimeStore } from './time.store';
|
||||||
|
|
||||||
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
const START = new Date('2026-09-22T12:00:00Z');
|
||||||
|
|
||||||
|
describe('TimeStore', () => {
|
||||||
|
let time: TimeStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(START);
|
||||||
|
time = new TimeStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the world’s own time until asked otherwise', () => {
|
||||||
|
const opened = time.julianDate();
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
|
||||||
|
expect(time.julianDate() - opened).toBeCloseTo(10_000 / MS_PER_DAY, 9);
|
||||||
|
expect(time.date().toISOString()).toBe('2026-09-22T12:00:10.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the sky faster without moving where it starts from', () => {
|
||||||
|
const opened = time.julianDate();
|
||||||
|
time.setRate(3600);
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
|
||||||
|
// A second of watching is an hour of sky, and the date did not jump when the rate changed.
|
||||||
|
expect(time.julianDate() - opened).toBeCloseTo(1 / 24, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries on from where it had got to when the rate changes again', () => {
|
||||||
|
time.setRate(86_400);
|
||||||
|
vi.advanceTimersByTime(2000); // two days of sky
|
||||||
|
const afterTwoDays = time.julianDate();
|
||||||
|
|
||||||
|
time.setRate(1);
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
|
||||||
|
// Slowing down keeps the two days: it does not rewind to the wall clock.
|
||||||
|
expect(time.julianDate() - afterTwoDays).toBeCloseTo(1000 / MS_PER_DAY, 9);
|
||||||
|
expect(time.julianDate() - afterTwoDays).toBeLessThan(1 / 24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('knows the map is away from now even once it is back at real time', () => {
|
||||||
|
expect(time.atNow()).toBe(true);
|
||||||
|
time.setRate(2_629_800);
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
time.setRate(1);
|
||||||
|
|
||||||
|
// Real time, months ahead: the rate says nothing about where the clock stands.
|
||||||
|
expect(time.atNow()).toBe(false);
|
||||||
|
time.reset();
|
||||||
|
expect(time.atNow()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('comes back to now, at real time', () => {
|
||||||
|
time.setRate(2_629_800);
|
||||||
|
vi.advanceTimersByTime(5000); // months away
|
||||||
|
expect(time.date().getUTCFullYear()).toBeGreaterThan(START.getUTCFullYear());
|
||||||
|
|
||||||
|
time.reset();
|
||||||
|
|
||||||
|
expect(time.rate()).toBe(1);
|
||||||
|
// Now, not the moment the store was built: five seconds of wall clock have passed.
|
||||||
|
expect(time.date().toISOString()).toBe(new Date(START.getTime() + 5000).toISOString());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
import { dateToJulianDate } from '../astro/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How fast the map's clock runs, in seconds of sky per second of wall clock.
|
||||||
|
*
|
||||||
|
* The map is built on propagated orbits and published rotation periods, both of which are
|
||||||
|
* functions of a date — so the only thing standing between it and a working orrery is the number
|
||||||
|
* on this list. At real time nothing appears to move: Earth turns 15 degrees an hour and takes a
|
||||||
|
* year to go round, and a reader watching for a minute sees a still picture.
|
||||||
|
*
|
||||||
|
* An hour a second is the rate at which rotation reads — Jupiter turns once every ten seconds of
|
||||||
|
* watching. A day a second is the rate at which the inner planets read. A month a second carries
|
||||||
|
* the outer ones, at which point the inner four are a blur, which is honest: that is what the
|
||||||
|
* solar system does.
|
||||||
|
*/
|
||||||
|
export const TIME_RATES = [
|
||||||
|
{ label: 'Real time', secondsPerSecond: 1 },
|
||||||
|
{ label: '1 h/s', secondsPerSecond: 3600 },
|
||||||
|
{ label: '1 d/s', secondsPerSecond: 86_400 },
|
||||||
|
{ label: '1 mo/s', secondsPerSecond: 2_629_800 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
const JULIAN_DATE_AT_EPOCH = 2440587.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date the map is drawn for.
|
||||||
|
*
|
||||||
|
* Read every frame rather than held in a signal: it changes continuously, and a signal that
|
||||||
|
* changed sixty times a second would ask the whole HUD to re-render for a number nothing is
|
||||||
|
* watching. The rate *is* a signal, since a reader sets it and the controls read it back.
|
||||||
|
*
|
||||||
|
* Changing the rate re-anchors instead of rewinding: the date carries on from where it had got
|
||||||
|
* to, so speeding up and slowing down never jumps the sky.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class TimeStore {
|
||||||
|
readonly rate = signal<number>(TIME_RATES[0].secondsPerSecond);
|
||||||
|
/**
|
||||||
|
* Whether the map is drawn for the present. Not the same as a rate of one: after an excursion at
|
||||||
|
* a month a second, real time carries on from months ahead, and the map is still away from now.
|
||||||
|
*/
|
||||||
|
readonly atNow = signal(true);
|
||||||
|
|
||||||
|
private anchorJd = dateToJulianDate();
|
||||||
|
private anchorWallMs = Date.now();
|
||||||
|
|
||||||
|
/** Julian date for this instant, at the rate the reader chose. */
|
||||||
|
julianDate(): number {
|
||||||
|
return this.anchorJd + ((Date.now() - this.anchorWallMs) * this.rate()) / MS_PER_DAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same instant as a date, for anything that prints it.
|
||||||
|
*
|
||||||
|
* Rounded to the millisecond, which is all a `Date` holds: a Julian date near 2 461 000 has
|
||||||
|
* about a twentieth of a millisecond of resolution left in a double, and `new Date` truncates
|
||||||
|
* what is left rather than rounding it, so ten seconds came back as 9.999.
|
||||||
|
*/
|
||||||
|
date(): Date {
|
||||||
|
return new Date(Math.round((this.julianDate() - JULIAN_DATE_AT_EPOCH) * MS_PER_DAY));
|
||||||
|
}
|
||||||
|
|
||||||
|
setRate(secondsPerSecond: number): void {
|
||||||
|
this.anchorJd = this.julianDate();
|
||||||
|
this.anchorWallMs = Date.now();
|
||||||
|
this.rate.set(secondsPerSecond);
|
||||||
|
if (secondsPerSecond !== 1) {
|
||||||
|
this.atNow.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back to now, at real time — the state the map opens in. */
|
||||||
|
reset(): void {
|
||||||
|
this.anchorJd = dateToJulianDate();
|
||||||
|
this.anchorWallMs = Date.now();
|
||||||
|
this.rate.set(TIME_RATES[0].secondsPerSecond);
|
||||||
|
this.atNow.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
-18
@@ -13,7 +13,9 @@
|
|||||||
"argumentOfPeriapsisDeg": 29.19561108948743,
|
"argumentOfPeriapsisDeg": 29.19561108948743,
|
||||||
"meanAnomalyAtEpochDeg": 103.9465145977117,
|
"meanAnomalyAtEpochDeg": 103.9465145977117,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
}
|
},
|
||||||
|
"rotationPeriodHours": 1407.512239412851,
|
||||||
|
"obliquityDeg": 2.11
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "venus",
|
"id": "venus",
|
||||||
@@ -29,7 +31,9 @@
|
|||||||
"argumentOfPeriapsisDeg": 55.15075425343929,
|
"argumentOfPeriapsisDeg": 55.15075425343929,
|
||||||
"meanAnomalyAtEpochDeg": 280.0749102629981,
|
"meanAnomalyAtEpochDeg": 280.0749102629981,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
}
|
},
|
||||||
|
"rotationPeriodHours": -5832.539941165383,
|
||||||
|
"obliquityDeg": 177.3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "earth",
|
"id": "earth",
|
||||||
@@ -45,7 +49,9 @@
|
|||||||
"argumentOfPeriapsisDeg": 272.9783142442708,
|
"argumentOfPeriapsisDeg": 272.9783142442708,
|
||||||
"meanAnomalyAtEpochDeg": 357.4122246804211,
|
"meanAnomalyAtEpochDeg": 357.4122246804211,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
}
|
},
|
||||||
|
"rotationPeriodHours": 23.934472399219285,
|
||||||
|
"obliquityDeg": 23.4392911
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "mars",
|
"id": "mars",
|
||||||
@@ -61,7 +67,9 @@
|
|||||||
"argumentOfPeriapsisDeg": 286.7114828288203,
|
"argumentOfPeriapsisDeg": 286.7114828288203,
|
||||||
"meanAnomalyAtEpochDeg": 124.444888195349,
|
"meanAnomalyAtEpochDeg": 124.444888195349,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
}
|
},
|
||||||
|
"rotationPeriodHours": 24.622955438662025,
|
||||||
|
"obliquityDeg": 25.19
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "jupiter",
|
"id": "jupiter",
|
||||||
@@ -77,7 +85,9 @@
|
|||||||
"argumentOfPeriapsisDeg": 273.6090683600047,
|
"argumentOfPeriapsisDeg": 273.6090683600047,
|
||||||
"meanAnomalyAtEpochDeg": 58.98282567286461,
|
"meanAnomalyAtEpochDeg": 58.98282567286461,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
}
|
},
|
||||||
|
"rotationPeriodHours": 9.925102371306965,
|
||||||
|
"obliquityDeg": 3.13
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "saturn",
|
"id": "saturn",
|
||||||
@@ -93,7 +103,9 @@
|
|||||||
"argumentOfPeriapsisDeg": 337.1663598259115,
|
"argumentOfPeriapsisDeg": 337.1663598259115,
|
||||||
"meanAnomalyAtEpochDeg": 264.9877650588944,
|
"meanAnomalyAtEpochDeg": 264.9877650588944,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
}
|
},
|
||||||
|
"rotationPeriodHours": 10.656221583138441,
|
||||||
|
"obliquityDeg": 26.73
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "uranus",
|
"id": "uranus",
|
||||||
@@ -109,7 +121,9 @@
|
|||||||
"argumentOfPeriapsisDeg": 90.49593456147204,
|
"argumentOfPeriapsisDeg": 90.49593456147204,
|
||||||
"meanAnomalyAtEpochDeg": 255.8822481742851,
|
"meanAnomalyAtEpochDeg": 255.8822481742851,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
}
|
},
|
||||||
|
"rotationPeriodHours": -17.24003330792427,
|
||||||
|
"obliquityDeg": 97.77
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "neptune",
|
"id": "neptune",
|
||||||
@@ -125,7 +139,9 @@
|
|||||||
"argumentOfPeriapsisDeg": 268.1146665565159,
|
"argumentOfPeriapsisDeg": 268.1146665565159,
|
||||||
"meanAnomalyAtEpochDeg": 319.6858384317641,
|
"meanAnomalyAtEpochDeg": 319.6858384317641,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
}
|
},
|
||||||
|
"rotationPeriodHours": 16.110037586020876,
|
||||||
|
"obliquityDeg": 28.32
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "pluto",
|
"id": "pluto",
|
||||||
@@ -141,7 +157,9 @@
|
|||||||
"argumentOfPeriapsisDeg": 113.5754868232679,
|
"argumentOfPeriapsisDeg": 113.5754868232679,
|
||||||
"meanAnomalyAtEpochDeg": 51.93655343727463,
|
"meanAnomalyAtEpochDeg": 51.93655343727463,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
}
|
},
|
||||||
|
"rotationPeriodHours": 153.29335198,
|
||||||
|
"obliquityDeg": 119.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "moon",
|
"id": "moon",
|
||||||
@@ -158,7 +176,9 @@
|
|||||||
"meanAnomalyAtEpochDeg": 290.7825171697369,
|
"meanAnomalyAtEpochDeg": 290.7825171697369,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "earth"
|
"parentBodyId": "earth",
|
||||||
|
"rotationPeriodHours": 664.8546956215548,
|
||||||
|
"obliquityDeg": 6.67
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "phobos",
|
"id": "phobos",
|
||||||
@@ -175,7 +195,8 @@
|
|||||||
"meanAnomalyAtEpochDeg": 342.6005509941174,
|
"meanAnomalyAtEpochDeg": 342.6005509941174,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "mars"
|
"parentBodyId": "mars",
|
||||||
|
"rotationPeriodHours": 7.660212212136228
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "deimos",
|
"id": "deimos",
|
||||||
@@ -192,7 +213,8 @@
|
|||||||
"meanAnomalyAtEpochDeg": 273.8943716897566,
|
"meanAnomalyAtEpochDeg": 273.8943716897566,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "mars"
|
"parentBodyId": "mars",
|
||||||
|
"rotationPeriodHours": 30.304279685850094
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "io",
|
"id": "io",
|
||||||
@@ -209,7 +231,8 @@
|
|||||||
"meanAnomalyAtEpochDeg": 74.88524962049125,
|
"meanAnomalyAtEpochDeg": 74.88524962049125,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "jupiter"
|
"parentBodyId": "jupiter",
|
||||||
|
"rotationPeriodHours": 42.51537583211252
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "europa",
|
"id": "europa",
|
||||||
@@ -226,7 +249,8 @@
|
|||||||
"meanAnomalyAtEpochDeg": 40.72575250295771,
|
"meanAnomalyAtEpochDeg": 40.72575250295771,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "jupiter"
|
"parentBodyId": "jupiter",
|
||||||
|
"rotationPeriodHours": 85.27848729079142
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ganymede",
|
"id": "ganymede",
|
||||||
@@ -243,7 +267,8 @@
|
|||||||
"meanAnomalyAtEpochDeg": 355.7249344187845,
|
"meanAnomalyAtEpochDeg": 355.7249344187845,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "jupiter"
|
"parentBodyId": "jupiter",
|
||||||
|
"rotationPeriodHours": 171.78271980469088
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "callisto",
|
"id": "callisto",
|
||||||
@@ -260,7 +285,8 @@
|
|||||||
"meanAnomalyAtEpochDeg": 126.0198540648681,
|
"meanAnomalyAtEpochDeg": 126.0198540648681,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "jupiter"
|
"parentBodyId": "jupiter",
|
||||||
|
"rotationPeriodHours": 400.52470451330635
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "titan",
|
"id": "titan",
|
||||||
@@ -277,7 +303,8 @@
|
|||||||
"meanAnomalyAtEpochDeg": 32.18862839469676,
|
"meanAnomalyAtEpochDeg": 32.18862839469676,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "saturn"
|
"parentBodyId": "saturn",
|
||||||
|
"rotationPeriodHours": 382.87527117206236
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "triton",
|
"id": "triton",
|
||||||
@@ -294,6 +321,7 @@
|
|||||||
"meanAnomalyAtEpochDeg": 273.1157438944629,
|
"meanAnomalyAtEpochDeg": 273.1157438944629,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "neptune"
|
"parentBodyId": "neptune",
|
||||||
|
"rotationPeriodHours": 141.05626027628523
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
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.
+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 { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
|
||||||
import { fetchStars } from './fetchStars';
|
import { fetchStars } from './fetchStars';
|
||||||
import { describeSources } from './sources/registry';
|
import { describeSources } from './sources/registry';
|
||||||
import { rematchHostStars } from '../../src/app/shared/astro/host-star-matching';
|
|
||||||
import { dataPath } from './lib/paths';
|
import { dataPath } from './lib/paths';
|
||||||
|
|
||||||
class ValidationError extends Error {}
|
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 {
|
function validateBodies(bodies: BodyRecord[]): void {
|
||||||
assertCondition(bodies.length > 0, 'No solar-system bodies were produced.');
|
assertCondition(bodies.length > 0, 'No solar-system bodies were produced.');
|
||||||
|
|
||||||
@@ -161,20 +241,9 @@ async function build(): Promise<void> {
|
|||||||
const deepSky = await fetchDeepSky();
|
const deepSky = await fetchDeepSky();
|
||||||
console.log();
|
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...');
|
console.log('Validating output...');
|
||||||
validateStars(stars);
|
validateStars(stars);
|
||||||
|
validateMerge(stars);
|
||||||
validateBodies(bodies);
|
validateBodies(bodies);
|
||||||
validateExoplanets(exoplanets, new Set(stars.map((star) => star.id)));
|
validateExoplanets(exoplanets, new Set(stars.map((star) => star.id)));
|
||||||
validateDeepSky(deepSky);
|
validateDeepSky(deepSky);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { writeFileSync } from 'node:fs';
|
import { writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
import { buildStarNameIndex, resolveHostStarId } from '../../src/app/shared/astro/host-star-matching';
|
import { buildStarNameIndex, resolveHostStarId } from '../../src/app/shared/astro/host-star-matching';
|
||||||
@@ -15,6 +16,8 @@ const TAP_COLUMNS = [
|
|||||||
'ra',
|
'ra',
|
||||||
'dec',
|
'dec',
|
||||||
'sy_dist',
|
'sy_dist',
|
||||||
|
'sy_pmra',
|
||||||
|
'sy_pmdec',
|
||||||
'pl_orbsmax',
|
'pl_orbsmax',
|
||||||
'pl_orbeccen',
|
'pl_orbeccen',
|
||||||
'pl_orbincl',
|
'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_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}`;
|
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
|
// The cache is keyed by the request it answers — endpoint included, since the cache records
|
||||||
// accepted as a cross-reference (guards against coincidental name/position collisions).
|
// only that some response arrived: one cached before a column was added would otherwise keep
|
||||||
const MATCH_TOLERANCE_PC = 0.5;
|
// 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
|
* 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 knownStars = stars ?? (await fetchStars());
|
||||||
const nameIndex = buildStarNameIndex(knownStars);
|
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);
|
const rows = parseCsvObjects(csv);
|
||||||
|
|
||||||
let matched = 0;
|
let matched = 0;
|
||||||
@@ -55,11 +60,12 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
|
|||||||
const raDeg = parseOptionalNumber(row['ra']) ?? Number.NaN;
|
const raDeg = parseOptionalNumber(row['ra']) ?? Number.NaN;
|
||||||
const decDeg = parseOptionalNumber(row['dec']) ?? Number.NaN;
|
const decDeg = parseOptionalNumber(row['dec']) ?? Number.NaN;
|
||||||
const distancePc = parseOptionalNumber(row['sy_dist']) ?? 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(
|
const hostStarId = resolveHostStarId(
|
||||||
{ hostname: row['hostname'], raDeg, decDeg, distancePc },
|
{ hostname: row['hostname'], raDeg, decDeg, distancePc, pmRaMasPerYear, pmDecMasPerYear },
|
||||||
knownStars,
|
knownStars,
|
||||||
MATCH_TOLERANCE_PC,
|
|
||||||
nameIndex
|
nameIndex
|
||||||
);
|
);
|
||||||
if (hostStarId !== null) {
|
if (hostStarId !== null) {
|
||||||
@@ -84,6 +90,8 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
|
|||||||
hostRaDeg: parseOptionalNumber(row['ra']),
|
hostRaDeg: parseOptionalNumber(row['ra']),
|
||||||
hostDecDeg: parseOptionalNumber(row['dec']),
|
hostDecDeg: parseOptionalNumber(row['dec']),
|
||||||
hostDistancePc: parseOptionalNumber(row['sy_dist']),
|
hostDistancePc: parseOptionalNumber(row['sy_dist']),
|
||||||
|
hostPmRaMasPerYear: pmRaMasPerYear,
|
||||||
|
hostPmDecMasPerYear: pmDecMasPerYear,
|
||||||
orbit: {
|
orbit: {
|
||||||
semiMajorAxisAu: parseOptionalNumber(row['pl_orbsmax']),
|
semiMajorAxisAu: parseOptionalNumber(row['pl_orbsmax']),
|
||||||
eccentricity: parseOptionalNumber(row['pl_orbeccen']),
|
eccentricity: parseOptionalNumber(row['pl_orbeccen']),
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { writeFileSync } from 'node:fs';
|
import { writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
import { BodyRecord } from '../../src/app/shared/models/body.model';
|
import { BodyRecord } from '../../src/app/shared/models/body.model';
|
||||||
|
import { gmForParent } from '../../src/app/shared/astro/constants';
|
||||||
|
import { orbitalPeriodDays } from '../../src/app/shared/astro/kepler';
|
||||||
import { SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
import { SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
||||||
import { fetchHorizonsBody } from './lib/horizons';
|
import { fetchHorizonsBody } from './lib/horizons';
|
||||||
import { dataPath, ensureDataDir } from './lib/paths';
|
import { dataPath, ensureDataDir } from './lib/paths';
|
||||||
|
|
||||||
|
const HOURS_PER_DAY = 24;
|
||||||
|
|
||||||
interface BodySpec {
|
interface BodySpec {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -12,6 +16,11 @@ interface BodySpec {
|
|||||||
horizonsCommand: string;
|
horizonsCommand: string;
|
||||||
center: string;
|
center: string;
|
||||||
parentBodyId?: string;
|
parentBodyId?: string;
|
||||||
|
/**
|
||||||
|
* Obliquity to orbit, in degrees, where the Horizons page states none. Pluto's is from the IAU
|
||||||
|
* WGCCRE 2015 pole (RA 132.99, Dec -6.16), 119.6 degrees: past 90, so it turns retrograde.
|
||||||
|
*/
|
||||||
|
obliquityDeg?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sun-centered planets/dwarf, then their major moons (planetocentric elements).
|
// Sun-centered planets/dwarf, then their major moons (planetocentric elements).
|
||||||
@@ -24,7 +33,7 @@ const BODY_SPECS: BodySpec[] = [
|
|||||||
{ id: 'saturn', name: 'Saturn', kind: 'planet', horizonsCommand: '699', center: '500@10' },
|
{ id: 'saturn', name: 'Saturn', kind: 'planet', horizonsCommand: '699', center: '500@10' },
|
||||||
{ id: 'uranus', name: 'Uranus', kind: 'planet', horizonsCommand: '799', center: '500@10' },
|
{ id: 'uranus', name: 'Uranus', kind: 'planet', horizonsCommand: '799', center: '500@10' },
|
||||||
{ id: 'neptune', name: 'Neptune', kind: 'planet', horizonsCommand: '899', center: '500@10' },
|
{ id: 'neptune', name: 'Neptune', kind: 'planet', horizonsCommand: '899', center: '500@10' },
|
||||||
{ id: 'pluto', name: 'Pluto', kind: 'dwarf', horizonsCommand: '999', center: '500@10' },
|
{ id: 'pluto', name: 'Pluto', kind: 'dwarf', horizonsCommand: '999', center: '500@10', obliquityDeg: 119.6 },
|
||||||
{ id: 'moon', name: 'Moon', kind: 'moon', horizonsCommand: '301', center: '500@399', parentBodyId: 'earth' },
|
{ id: 'moon', name: 'Moon', kind: 'moon', horizonsCommand: '301', center: '500@399', parentBodyId: 'earth' },
|
||||||
{ id: 'phobos', name: 'Phobos', kind: 'moon', horizonsCommand: '401', center: '500@499', parentBodyId: 'mars' },
|
{ id: 'phobos', name: 'Phobos', kind: 'moon', horizonsCommand: '401', center: '500@499', parentBodyId: 'mars' },
|
||||||
{ id: 'deimos', name: 'Deimos', kind: 'moon', horizonsCommand: '402', center: '500@499', parentBodyId: 'mars' },
|
{ id: 'deimos', name: 'Deimos', kind: 'moon', horizonsCommand: '402', center: '500@499', parentBodyId: 'mars' },
|
||||||
@@ -56,6 +65,18 @@ export async function fetchSolarSystem(): Promise<BodyRecord[]> {
|
|||||||
console.warn(` no physical radius found for ${spec.name}; defaulting to 0.`);
|
console.warn(` no physical radius found for ${spec.name}; defaulting to 0.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every moon listed here is tidally locked, so its day is its orbit — as drawn, from these
|
||||||
|
// elements and the parent's mass by Kepler. Not every page says so: the Moon's gives a rate,
|
||||||
|
// the true sidereal month, 1.4% off the orbit these elements trace, so its face drifted five
|
||||||
|
// degrees an orbit; Titan's gives nothing, so it did not turn. Taking the orbit keeps one face
|
||||||
|
// towards the parent, which is what synchronous means.
|
||||||
|
const rotationPeriodHours = result.tidallyLocked || spec.kind === 'moon'
|
||||||
|
? orbitalPeriodDays(result.orbit.semiMajorAxisAu, gmForParent(spec.parentBodyId)) * HOURS_PER_DAY
|
||||||
|
: result.rotationPeriodHours;
|
||||||
|
if (rotationPeriodHours === undefined) {
|
||||||
|
console.warn(` no rotation period found for ${spec.name}; it will not turn.`);
|
||||||
|
}
|
||||||
|
|
||||||
bodies.push({
|
bodies.push({
|
||||||
id: spec.id,
|
id: spec.id,
|
||||||
systemStarId: SUN_STAR_ID,
|
systemStarId: SUN_STAR_ID,
|
||||||
@@ -63,7 +84,9 @@ export async function fetchSolarSystem(): Promise<BodyRecord[]> {
|
|||||||
kind: spec.kind,
|
kind: spec.kind,
|
||||||
radiusKm: result.radiusKm ?? 0,
|
radiusKm: result.radiusKm ?? 0,
|
||||||
orbit: result.orbit,
|
orbit: result.orbit,
|
||||||
...(spec.parentBodyId ? { parentBodyId: spec.parentBodyId } : {})
|
...(spec.parentBodyId ? { parentBodyId: spec.parentBodyId } : {}),
|
||||||
|
...(rotationPeriodHours !== undefined ? { rotationPeriodHours } : {}),
|
||||||
|
...((result.obliquityDeg ?? spec.obliquityDeg) !== undefined ? { obliquityDeg: result.obliquityDeg ?? spec.obliquityDeg } : {})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+59
-26
@@ -1,9 +1,9 @@
|
|||||||
import { writeFileSync } from 'node:fs';
|
import { writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
import { raDecDistanceToXyz } from '../../src/app/shared/astro/coordinates';
|
import { mergeStarCatalogues, placementDistancePc } from '../../src/app/shared/astro/star-merge';
|
||||||
import { mergeStarCatalogues } from '../../src/app/shared/astro/star-merge';
|
|
||||||
import { encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
|
import { encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
|
||||||
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
||||||
|
import { fetchGaiaDistancesByHip, GaiaAnswerError } from './sources/gaia';
|
||||||
import { positionalSources } from './sources/registry';
|
import { positionalSources } from './sources/registry';
|
||||||
import { PARALLAX_PRECISION_MAS } from './sources/star-sources';
|
import { PARALLAX_PRECISION_MAS } from './sources/star-sources';
|
||||||
import { parseCsvObjects, parseOptionalNumber } from './lib/csv';
|
import { parseCsvObjects, parseOptionalNumber } from './lib/csv';
|
||||||
@@ -20,13 +20,14 @@ const HYG_UNKNOWN_DISTANCE_PC = 100000; // HYG's placeholder for unmeasured/unre
|
|||||||
const UNKNOWN_MAGNITUDE = 15;
|
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
|
* Set at the range Hipparcos's own measurements reach rather than at a round number: its
|
||||||
* rows carry a Hipparcos identifier, and Hipparcos parallaxes are good to roughly a
|
* parallaxes are good to roughly a milliarcsecond, so at 250 pc (4 mas) a distance is uncertain
|
||||||
* milliarcsecond — so at 250 pc (4 mas) a star's distance is uncertain by some tens of per
|
* by some tens of per cent. That is why it is not applied to the Hipparcos distance alone:
|
||||||
* cent, and beyond it the catalogue is plotting noise. Note that only the *radial* placement
|
* Gaia puts 6 833 of the stars Hipparcos places inside it outside, and 3 666 the other way
|
||||||
* blurs: a star's direction on the sky stays exact at any distance.
|
* 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
|
* 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.
|
* 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
|
* Downloads the HYG (Hipparcos/Yale/Gliese) stellar database, places each star along its
|
||||||
* RA/Dec/distance into galaxy-scale Cartesian coordinates (parsecs), filters by distance,
|
* equatorial direction (epoch J2000.0) at the better of its Hipparcos and Gaia distances, keeps
|
||||||
* and writes `stars.bin` (packed positions) + `stars-index.json` (everything else).
|
* 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[]> {
|
export async function fetchStars(): Promise<StarRecord[]> {
|
||||||
console.log(`Fetching HYG star catalog (distance cutoff: ${DISTANCE_CUTOFF_PC} pc)...`);
|
console.log(`Fetching HYG star catalog (distance cutoff: ${DISTANCE_CUTOFF_PC} pc)...`);
|
||||||
const csv = await fetchTextCached(HYG_CSV_URL, 'hygdata_v41.csv');
|
const csv = await fetchTextCached(HYG_CSV_URL, 'hygdata_v41.csv');
|
||||||
const rows = parseCsvObjects(csv);
|
const rows = parseCsvObjects(csv);
|
||||||
|
// Not skipped when unreachable, unlike the positional sources below; see its own comment.
|
||||||
|
const gaiaPcByHip = await fetchGaiaDistancesByHip();
|
||||||
|
|
||||||
const stars: StarRecord[] = [];
|
const stars: StarRecord[] = [];
|
||||||
|
let atGaiaDistance = 0;
|
||||||
|
let pastCutoff = 0;
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const id = Number(row['id']);
|
const id = Number(row['id']);
|
||||||
const distancePc = Number(row['dist']);
|
|
||||||
|
|
||||||
if (id === SUN_STAR_ID) {
|
if (id === SUN_STAR_ID) {
|
||||||
stars.push({ id, name: 'Sol', x: 0, y: 0, z: 0, magnitude: parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE, spectralType: row['spect'] || 'G2V', colorIndex: parseOptionalNumber(row['ci']) ?? null });
|
stars.push({ id, name: 'Sol', x: 0, y: 0, z: 0, magnitude: parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE, spectralType: row['spect'] || 'G2V', colorIndex: parseOptionalNumber(row['ci']) ?? null });
|
||||||
continue;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const raHours = Number(row['ra']);
|
// HYG's own Cartesian columns rather than its `ra`/`dec`, which are in the same frame as
|
||||||
const decDeg = Number(row['dec']);
|
// `raDecDistanceToXyz` and would be redundant if the two agreed. They do not, for the stars
|
||||||
if (!Number.isFinite(raHours) || !Number.isFinite(decDeg)) {
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
const scale = distancePc / length;
|
||||||
const { x, y, z } = raDecDistanceToXyz(raHours, decDeg, distancePc);
|
if (gaiaPc !== undefined) {
|
||||||
|
atGaiaDistance++;
|
||||||
|
}
|
||||||
|
if (distancePc > DISTANCE_CUTOFF_PC) {
|
||||||
|
pastCutoff++;
|
||||||
|
}
|
||||||
|
|
||||||
stars.push({
|
stars.push({
|
||||||
id,
|
id,
|
||||||
name: resolveName(row),
|
name: resolveName(row),
|
||||||
x,
|
x: x * scale,
|
||||||
y,
|
y: y * scale,
|
||||||
z,
|
z: z * scale,
|
||||||
magnitude: parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE,
|
magnitude: parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE,
|
||||||
spectralType: row['spect'] || 'Unknown',
|
spectralType: row['spect'] || 'Unknown',
|
||||||
colorIndex: parseOptionalNumber(row['ci']) ?? null
|
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);
|
const merged = await mergeWithOtherSources(stars);
|
||||||
merged.sort((a, b) => a.id - b.id);
|
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.
|
* 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
|
* A source that cannot be reached is reported and skipped here rather than thrown, so a run still
|
||||||
* not defensive padding: the archives this would draw on are frequently unavailable, and a build
|
* gets as far as validation and says what it has. Whether that may be published is decided
|
||||||
* that produces a smaller catalogue is far better than one that produces none.
|
* 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[]> {
|
async function mergeWithOtherSources(hygStars: StarRecord[]): Promise<StarRecord[]> {
|
||||||
const others = positionalSources().filter((source) => source.id !== 'hyg');
|
const others = positionalSources().filter((source) => source.id !== 'hyg');
|
||||||
@@ -134,6 +162,11 @@ async function mergeWithOtherSources(hygStars: StarRecord[]): Promise<StarRecord
|
|||||||
stars: await source.fetch!()
|
stars: await source.fetch!()
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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}`);
|
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);
|
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)) {
|
for (const [sourceId, count] of Object.entries(summary.bySource)) {
|
||||||
console.log(` ${sourceId}: ${count}`);
|
console.log(` ${sourceId}: ${count}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ export interface HorizonsQuery {
|
|||||||
export interface HorizonsResult {
|
export interface HorizonsResult {
|
||||||
radiusKm?: number;
|
radiusKm?: number;
|
||||||
orbit: OrbitalElements;
|
orbit: OrbitalElements;
|
||||||
|
/**
|
||||||
|
* Sidereal rotation period in hours, negative where the page gives a negative rate —
|
||||||
|
* Venus and Uranus. Absent where the page publishes none. The same pages also give an obliquity
|
||||||
|
* past 90 degrees for those two, which says the same thing again; see the renderer's spinFor.
|
||||||
|
*/
|
||||||
|
rotationPeriodHours?: number;
|
||||||
|
/** Tilt of the rotation axis from the body's own orbital plane, in degrees. */
|
||||||
|
obliquityDeg?: number;
|
||||||
|
/** The page says "Synchronous" instead of a period: its day is its orbit. */
|
||||||
|
tidallyLocked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RADIUS_PATTERNS = [
|
const RADIUS_PATTERNS = [
|
||||||
@@ -30,10 +40,66 @@ const RADIUS_PATTERNS = [
|
|||||||
/Radius\s*\(gravity\),?\s*km\s*=\s*([\d.]+)/i
|
/Radius\s*\(gravity\),?\s*km\s*=\s*([\d.]+)/i
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How each page states how fast the body turns, in the order they are tried.
|
||||||
|
*
|
||||||
|
* The rate in radians per second is preferred wherever it appears: it is unambiguous and it is
|
||||||
|
* signed: Venus and Uranus carry a negative one. A period in hours or days
|
||||||
|
* comes next, then the sexagesimal form the giant planets use, and finally the word most moons carry
|
||||||
|
* instead of a number, Synchronous. Not all do — the Moon's page gives a rate, Titan's nothing —
|
||||||
|
* so the caller treats every moon it lists as locked whatever its page says.
|
||||||
|
*/
|
||||||
|
const ROTATION_RATE_PATTERN = /Rot(?:ational)?\.?\s*Rate\s*[(,]\s*rad\/s\s*\)?\s*=\s*(-?[\d.]+)/i;
|
||||||
|
const ROTATION_PERIOD_PATTERNS = [
|
||||||
|
/Sid(?:ereal|\.)?\s*rot\.?\s*period[^=]*=\s*(-?[\d.]+)(?:\+-[\d.]+)?\s*(h|hr|hrs|d|day|days)\b/i,
|
||||||
|
/Rotation(?:al)?\s*period[^=]*=\s*(-?[\d.]+)\s*(h|hr|hrs|d|day|days)\b/i
|
||||||
|
];
|
||||||
|
/** `9h 55m 29.711 s`, as Jupiter and Saturn state it. */
|
||||||
|
const SEXAGESIMAL_ROTATION_PATTERN = /Sid(?:ereal|\.)?\s*rot\.?\s*period[^=]*=\s*(\d+)\s*h\s*(\d+)\s*m\s*([\d.]+)\s*s/i;
|
||||||
|
const SYNCHRONOUS_PATTERN = /Rotation(?:al)?\s*period\s*=?\s*:?\s*Synchronous/i;
|
||||||
|
const OBLIQUITY_PATTERN = /Obliquity\s*to\s*orbit[^=]*=\s*(-?[\d.]+)/i;
|
||||||
|
|
||||||
|
const HOURS_PER_DAY = 24;
|
||||||
|
const SECONDS_PER_HOUR = 3600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True where the page gives no number because the body keeps one face to its parent, so its day
|
||||||
|
* is its orbit. The period itself is then Kepler's, which the caller
|
||||||
|
* works out from the elements above and the parent's mass.
|
||||||
|
*/
|
||||||
|
export function isTidallyLocked(text: string): boolean {
|
||||||
|
return SYNCHRONOUS_PATTERN.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sidereal rotation period, in hours, from whichever form the page states it in. */
|
||||||
|
export function extractRotationPeriodHours(text: string): number | undefined {
|
||||||
|
const rate = text.match(ROTATION_RATE_PATTERN);
|
||||||
|
if (rate && Number(rate[1]) !== 0) {
|
||||||
|
return (2 * Math.PI) / (Number(rate[1]) * SECONDS_PER_HOUR);
|
||||||
|
}
|
||||||
|
const sexagesimal = text.match(SEXAGESIMAL_ROTATION_PATTERN);
|
||||||
|
if (sexagesimal) {
|
||||||
|
return Number(sexagesimal[1]) + Number(sexagesimal[2]) / 60 + Number(sexagesimal[3]) / SECONDS_PER_HOUR;
|
||||||
|
}
|
||||||
|
for (const pattern of ROTATION_PERIOD_PATTERNS) {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
const hours = Number(match[1]) * (match[2].toLowerCase().startsWith('d') ? HOURS_PER_DAY : 1);
|
||||||
|
return Number.isFinite(hours) && hours !== 0 ? hours : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractObliquityDeg(text: string): number | undefined {
|
||||||
|
const match = text.match(OBLIQUITY_PATTERN);
|
||||||
|
return match ? Number(match[1]) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queries JPL Horizons for a body's heliocentric (or planetocentric, for moons) osculating
|
* Queries JPL Horizons for a body's heliocentric (or planetocentric, for moons) osculating
|
||||||
* orbital elements plus, when available, its mean physical radius — both in a single
|
* orbital elements plus, when available, its mean physical radius and how it turns — all in a
|
||||||
* request (`OBJ_DATA=YES` + `EPHEM_TYPE=ELEMENTS`).
|
* single request (`OBJ_DATA=YES` + `EPHEM_TYPE=ELEMENTS`).
|
||||||
*/
|
*/
|
||||||
export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsResult> {
|
export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsResult> {
|
||||||
const url =
|
const url =
|
||||||
@@ -44,7 +110,10 @@ export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsR
|
|||||||
const text = await fetchTextCached(url, query.cacheKey);
|
const text = await fetchTextCached(url, query.cacheKey);
|
||||||
return {
|
return {
|
||||||
radiusKm: extractRadiusKm(text),
|
radiusKm: extractRadiusKm(text),
|
||||||
orbit: extractOrbitalElements(text)
|
orbit: extractOrbitalElements(text),
|
||||||
|
rotationPeriodHours: extractRotationPeriodHours(text),
|
||||||
|
obliquityDeg: extractObliquityDeg(text),
|
||||||
|
tidallyLocked: isTidallyLocked(text)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-5
@@ -17,17 +17,44 @@ export async function fetchTextCached(url: string, cacheKey: string): Promise<st
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(` fetching ${url}`);
|
console.log(` fetching ${url}`);
|
||||||
const response = await fetch(url);
|
const text = await fetchText(url);
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
const text = await response.text();
|
|
||||||
|
|
||||||
mkdirSync(dirname(cachePath), { recursive: true });
|
mkdirSync(dirname(cachePath), { recursive: true });
|
||||||
writeFileSync(cachePath, text, 'utf-8');
|
writeFileSync(cachePath, text, 'utf-8');
|
||||||
return text;
|
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. */
|
/** Convenience wrapper around {@link fetchTextCached} that parses the cached response as JSON. */
|
||||||
export async function fetchJsonCached<T>(url: string, cacheKey: string): Promise<T> {
|
export async function fetchJsonCached<T>(url: string, cacheKey: string): Promise<T> {
|
||||||
return JSON.parse(await fetchTextCached(url, cacheKey)) as 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 { StarRecord } from '../../../src/app/shared/models/star.model';
|
||||||
import { parseCsvObjects, parseOptionalNumber } from '../lib/csv';
|
import { parseCsvObjects, parseOptionalNumber } from '../lib/csv';
|
||||||
import { fetchTextCached } from '../lib/http';
|
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
|
* 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.
|
* 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
|
* Written blind against the published DR3 schema, since no ESA endpoint was reachable from the
|
||||||
* the environment this was written in, so the query below is written against the published DR3
|
* environment it was written in; first run for real by the scheduled refresh of 2026-08-24, which
|
||||||
* schema and has not been executed against it. Treat the column names as the first thing to
|
* fetched 412 765 rows.
|
||||||
* check if a real run misbehaves.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const GAIA_TAP_URL = 'https://gea.esac.esa.int/tap-server/tap/sync';
|
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.
|
* 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
|
* 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.
|
* 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 DEFAULT_DISTANCE_CUTOFF_PC = 250;
|
||||||
const MAGNITUDE_LIMIT = Number(process.env['ETL_GAIA_MAGNITUDE_LIMIT'] ?? 12);
|
const DEFAULT_MAGNITUDE_LIMIT = 12;
|
||||||
const ROW_LIMIT = Number(process.env['ETL_GAIA_ROW_LIMIT'] ?? 500000);
|
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%
|
* 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 {
|
function buildQuery(): string {
|
||||||
return [
|
return [
|
||||||
`select top ${ROW_LIMIT}`,
|
`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',
|
'from gaiadr3.gaia_source',
|
||||||
`where parallax > ${parallaxFloorMas(DISTANCE_CUTOFF_PC).toFixed(6)}`,
|
`where parallax > ${parallaxFloorMas(DISTANCE_CUTOFF_PC).toFixed(6)}`,
|
||||||
`and parallax_over_error > ${(1 / MAX_PARALLAX_ERROR_RATIO).toFixed(1)}`,
|
`and parallax_over_error > ${(1 / MAX_PARALLAX_ERROR_RATIO).toFixed(1)}`,
|
||||||
`and phot_g_mean_mag < ${MAGNITUDE_LIMIT}`,
|
`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(' ');
|
].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
|
* 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
|
* 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;
|
const GAIA_ID_BASE = 1_000_000_000;
|
||||||
|
|
||||||
export async function fetchGaiaStars(): Promise<StarRecord[]> {
|
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)...`);
|
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 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[] = [];
|
const stars: StarRecord[] = [];
|
||||||
|
|
||||||
rows.forEach((row, index) => {
|
rows.forEach((row, index) => {
|
||||||
@@ -89,7 +153,8 @@ export async function fetchGaiaStars(): Promise<StarRecord[]> {
|
|||||||
return;
|
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({
|
stars.push({
|
||||||
id: GAIA_ID_BASE + index,
|
id: GAIA_ID_BASE + index,
|
||||||
name: `Gaia DR3 ${row['source_id']}`,
|
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).`);
|
console.log(` kept ${stars.length} Gaia stars (of ${rows.length} rows).`);
|
||||||
return stars;
|
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)',
|
name: 'HYG database (Hipparcos, Yale Bright Star, Gliese)',
|
||||||
role: 'positional',
|
role: 'positional',
|
||||||
endpoint: 'https://raw.githubusercontent.com/astronexus/HYG-Database',
|
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
|
unimplementedBecause: null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-1
@@ -9,6 +9,7 @@
|
|||||||
"src/**/*.ts"
|
"src/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"src/**/*.spec.ts"
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.worker.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./tsconfig.spec.json"
|
"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