Open the map out to the whole Milky Way
The map stopped at the catalogued 50 pc around the Sun — 0.33% of the Galaxy's width — and looked like a point cloud with a search box. Adds the galactic scale above it and the heads-up display the reference map is built from. The Galaxy is not a third coordinate space. It is the same parsec space four orders of magnitude further out, so the model and the star field crossfade against camera distance instead of switching, and the Sun stays where it really is: 8.18 kpc out, on the Orion Spur, between the Sagittarius and Perseus arms. The depth range scales with that distance — one fixed near/far pair cannot both fly into a star and hold the Galaxy. The structure in shared/astro/galaxy.ts is measured: the directions of the centre and the north galactic pole, which fix the disc's 63 degree tilt against the celestial equator; the Sun's galactocentric distance; and a radius, azimuth and pitch angle per arm. The particles scattered around it are not, and cannot be — dust hides the disc, so no catalogue holds the Galaxy's stars. The view says so, and the model fades out before the camera reaches the 50 pc where the real stars are. The rest is the look: polar grids lying in the galactic plane with drop lines from the Sun's neighbours, a scale ladder, a readout panel, range, reticle and frame brackets. Two things had to give way for it. The deep-sky shell is the sky as seen from here, so it dissolves rather than letting the camera fly through a wall of nebulae, and so does the skybox, which is a photograph taken from inside the thing now being viewed from outside. Labels are picked by screen separation rather than distance alone: the Sun's fifteen nearest neighbours are all inside four parsecs and printed as one unreadable clump. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
@@ -136,7 +136,10 @@ interface DeepSkyRecord {
|
|||||||
- `KeplerPropagator` (`shared/astro/kepler.ts`) — pure function(s) converting `OrbitalElements` + epoch → Cartesian position; independently unit-testable.
|
- `KeplerPropagator` (`shared/astro/kepler.ts`) — pure function(s) converting `OrbitalElements` + epoch → Cartesian position; independently unit-testable.
|
||||||
- `BodyDetailSceneComponent` — separate route/component with its own dedicated scene for a close-up view of one selected body, plus an `InfoPanelComponent` showing its data.
|
- `BodyDetailSceneComponent` — separate route/component with its own dedicated scene for a close-up view of one selected body, plus an `InfoPanelComponent` showing its data.
|
||||||
- `SearchComponent` — text search across `stars-index.json`, `bodies.json`, `exoplanets.json`; on match, dispatches a navigation action.
|
- `SearchComponent` — text search across `stars-index.json`, `bodies.json`, `exoplanets.json`; on match, dispatches a navigation action.
|
||||||
- `NavigationStore` (Angular signals-based) — holds `viewLevel: 'galaxy' | 'system'`, `selectedStarId`, `selectedBodyId`; consumed by scene components and routed body-detail view.
|
- `NavigationStore` (Angular signals-based) — holds `viewLevel: 'galactic' | 'galaxy' | 'system'`, `selectedStarId`, `selectedBodyId`; consumed by scene components and routed body-detail view.
|
||||||
|
- `MilkyWayRenderer` / `milky-way-model.ts` — the Galaxy itself as an instanced particle cloud scattered around the structural model in `shared/astro/galaxy.ts`, crossfaded against the catalogued star field by camera distance.
|
||||||
|
- `PolarGridPlane` / `TetherField` (`grid-plane.ts`) — the reference plane the map is read against: rings and spokes lying in the galactic plane, plus drop lines from stars to it.
|
||||||
|
- `StarmapHudComponent` — the heads-up display: scale ladder, readout panel, range, reticle and frame.
|
||||||
|
|
||||||
### File Structure
|
### File Structure
|
||||||
```
|
```
|
||||||
@@ -281,3 +284,10 @@ Users can search by name and jump directly to the matching star, system, or body
|
|||||||
- Implement `SearchComponent` querying `stars-index.json`, `bodies.json`, and `exoplanets.json` for name matches.
|
- Implement `SearchComponent` querying `stars-index.json`, `bodies.json`, and `exoplanets.json` for name matches.
|
||||||
- On selecting a search result, dispatch the appropriate `NavigationStore` update (galaxy star, system body, or exoplanet) and trigger the corresponding camera transition or route change.
|
- On selecting a search result, dispatch the appropriate `NavigationStore` update (galaxy star, system body, or exoplanet) and trigger the corresponding camera transition or route change.
|
||||||
- Ensure consistent state across `GalaxySystemSceneComponent` and `BodyDetailSceneComponent` when navigation originates from search rather than in-scene clicks.
|
- Ensure consistent state across `GalaxySystemSceneComponent` and `BodyDetailSceneComponent` when navigation originates from search rather than in-scene clicks.
|
||||||
|
|
||||||
|
### ✓ Step 7: Add the galactic scale and the heads-up display
|
||||||
|
The map opens out from the catalogued neighbourhood to the whole Milky Way, in the visual language of the reference.
|
||||||
|
- Add `shared/astro/galaxy.ts`: the galactic↔equatorial rotation, the Sun's galactocentric position, and logarithmic-spiral parameters per arm.
|
||||||
|
- Add `MilkyWayRenderer`, crossfaded against the star field by camera distance so the two scales share one continuous parsec space rather than being separate scenes.
|
||||||
|
- Add the galactic and local grid planes, star tethers, and the HUD (scale ladder, readout panel, range, reticle, frame).
|
||||||
|
- Label the Galaxy model as a model wherever it is shown: its skeleton is measured, its particles are not.
|
||||||
|
|||||||
@@ -29,8 +29,16 @@ npm run e2e:typecheck
|
|||||||
|
|
||||||
**Galaxy view** — every HYG-catalogue star within 50 parsecs as instanced camera-facing
|
**Galaxy view** — every HYG-catalogue star within 50 parsecs as instanced camera-facing
|
||||||
billboards, positioned from real RA/Dec/parallax, coloured by spectral index and sized by
|
billboards, positioned from real RA/Dec/parallax, coloured by spectral index and sized by
|
||||||
magnitude. Names label the stars nearest the camera. Behind them sits a backdrop of notable
|
magnitude. A polar grid in the galactic plane runs under them with a drop line from each of the
|
||||||
deep-sky objects and a Milky Way panorama.
|
Sun's nearest neighbours, and names label the stars nearest whatever the camera is looking at.
|
||||||
|
Behind them sits a backdrop of notable deep-sky objects and a Milky Way panorama.
|
||||||
|
|
||||||
|
**Galactic view** — keep pulling back and the neighbourhood becomes a point inside the Milky
|
||||||
|
Way: the bar and bulge, five spiral arms, the disc and a thin halo, with the arms and the
|
||||||
|
galactic centre named. It is the same parsec-scale space as the galaxy view, crossfaded by
|
||||||
|
camera distance rather than switched, so the Sun stays where it really is — 8.18 kpc out, on the
|
||||||
|
Orion Spur, between the Sagittarius and Perseus arms. See "On the Galaxy model" below for what
|
||||||
|
in it is measured and what is not.
|
||||||
|
|
||||||
**System view** — selecting a star flies the camera continuously into its system rather than
|
**System view** — selecting a star flies the camera continuously into its system rather than
|
||||||
cutting to a new scene. The Sun gets the real solar-system bodies from JPL Horizons; other
|
cutting to a new scene. The Sun gets the real solar-system bodies from JPL Horizons; other
|
||||||
@@ -63,9 +71,33 @@ same place an in-scene click would.
|
|||||||
about eight orders of magnitude apart, which wrecks float precision if rendered in one unit
|
about eight orders of magnitude apart, which wrecks float precision if rendered in one unit
|
||||||
space. The camera rig recentres the active star to the origin ("floating origin") and swaps
|
space. The camera rig recentres the active star to the origin ("floating origin") and swaps
|
||||||
the unit scale and near/far planes at the transition point.
|
the unit scale and near/far planes at the transition point.
|
||||||
|
- **The galactic scale is not a third space.** It is the same parsec space as the galaxy view,
|
||||||
|
four orders of magnitude further out, so no swap is needed — the Milky Way model and the
|
||||||
|
catalogued star field crossfade against camera distance and the depth range scales with it.
|
||||||
|
One fixed near/far pair cannot serve both ends: flying into a star needs a near plane a
|
||||||
|
hundredth of a parsec out, and holding the Galaxy needs a far plane a hundred thousand
|
||||||
|
parsecs out, and a projection spanning both has no precision left to separate one arm from
|
||||||
|
the next.
|
||||||
- **No backend.** Every dataset is baked at build time into `src/assets/data/` and served as a
|
- **No backend.** Every dataset is baked at build time into `src/assets/data/` and served as a
|
||||||
static asset. Nothing queries an astronomy API at runtime.
|
static asset. Nothing queries an astronomy API at runtime.
|
||||||
|
|
||||||
|
### On the Galaxy model
|
||||||
|
|
||||||
|
Every other dataset here is measured. The Galaxy is the exception, and not for want of trying:
|
||||||
|
we sit inside its disc, and dust blocks the view across it, so no catalogue holds the positions
|
||||||
|
of its stars. Every rendering of the Milky Way seen face-on — including NASA's — is a model.
|
||||||
|
|
||||||
|
What *is* measured is the skeleton, and that is what `shared/astro/galaxy.ts` contains: the
|
||||||
|
directions of the galactic centre and the north galactic pole, which fix the disc's 63° tilt
|
||||||
|
against the celestial equator; the 8.18 kpc from the Sun to the centre, from the orbit of the
|
||||||
|
star S2 around Sgr A*; and a reference radius, azimuth and pitch angle per spiral arm,
|
||||||
|
approximating the maser-parallax fits. The Sun's placement on the Orion Spur and the arms either
|
||||||
|
side of it follow from those numbers rather than being posed by hand.
|
||||||
|
|
||||||
|
The particles scattered around that skeleton are illustrative — a seeded, reproducible cloud, not
|
||||||
|
observations. The galactic view says so on screen, and the model fades out entirely before the
|
||||||
|
camera reaches the catalogued 50 pc the real stars occupy.
|
||||||
|
|
||||||
## Data pipeline
|
## Data pipeline
|
||||||
|
|
||||||
`npm run etl` runs `tools/etl/build.ts`, which fetches each source, writes the static assets,
|
`npm run etl` runs `tools/etl/build.ts`, which fetches each source, writes the static assets,
|
||||||
@@ -105,11 +137,13 @@ the 463 cataloged objects get a distance; the rest honestly report none.
|
|||||||
src/app/
|
src/app/
|
||||||
core/engine/ Three.js renderer, render loop, resize
|
core/engine/ Three.js renderer, render loop, resize
|
||||||
core/data/ static-asset loading and caching
|
core/data/ static-asset loading and caching
|
||||||
features/galaxy-system/ shared galaxy+system scene, camera rig, star field,
|
features/galaxy-system/ shared galactic+galaxy+system scene, camera rig, star field,
|
||||||
deep-sky backdrop, orbits, labels
|
Milky Way model, grid planes, deep-sky backdrop, orbits,
|
||||||
|
labels, HUD
|
||||||
features/body-detail/ close-up scene and info panel
|
features/body-detail/ close-up scene and info panel
|
||||||
features/search/ name search across every dataset
|
features/search/ name search across every dataset
|
||||||
shared/astro/ coordinates, Kepler propagator, deep-sky classification
|
shared/astro/ coordinates, Kepler propagator, deep-sky classification,
|
||||||
|
Milky Way structure
|
||||||
shared/models/ record contracts shared by the app and the ETL
|
shared/models/ record contracts shared by the app and the ETL
|
||||||
shared/rendering/ skybox, glow sprites, texture catalog
|
shared/rendering/ skybox, glow sprites, texture catalog
|
||||||
shared/state/ navigation store (Angular signals)
|
shared/state/ navigation store (Angular signals)
|
||||||
|
|||||||
+24
-1
@@ -1,11 +1,34 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { backButtonLocator } from './support/wait-for-back-button';
|
||||||
|
|
||||||
test.describe('Galaxy view', () => {
|
test.describe('Galaxy view', () => {
|
||||||
test('boots the app, initializes the 3D scene, and starts in the galaxy overview (no system controls shown)', async ({ page }) => {
|
test('boots the app, initializes the 3D scene, and starts in the galaxy overview (no system controls shown)', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
await expect(page.getByTestId('scene-canvas')).toBeVisible();
|
await expect(page.getByTestId('scene-canvas')).toBeVisible();
|
||||||
await expect(page.getByPlaceholder('Search stars, planets, exoplanets…')).toBeVisible();
|
await expect(page.getByPlaceholder('Search stars, planets, exoplanets…')).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Galaxy' })).toHaveCount(0);
|
await expect(backButtonLocator(page)).toHaveCount(0);
|
||||||
|
await expect(page.getByText('Local Stars')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the scale ladder flies out to the whole Galaxy and back to the solar neighbourhood', async ({ page }) => {
|
||||||
|
// Two multi-second camera flights, either side of a software-rendered scene bootstrap, add
|
||||||
|
// up to more than the default per-test budget.
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByTestId('scene-canvas')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Milky Way' }).click();
|
||||||
|
|
||||||
|
// The flight covers four orders of magnitude, and the readout only switches over once the
|
||||||
|
// camera is far enough out for the Galaxy model to have taken over from the star field.
|
||||||
|
await expect(page.getByText('Galactic Scale')).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.getByText('Sagittarius A*')).toBeVisible();
|
||||||
|
// At the outermost scale there is nowhere further out to go.
|
||||||
|
await expect(page.getByRole('button', { name: 'Milky Way' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Solar Neighbourhood' }).click();
|
||||||
|
await expect(page.getByText('Local Stars')).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,31 @@
|
|||||||
import { expect, Locator, Page } from '@playwright/test';
|
import { expect, Locator, Page } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The "Galaxy" back button is only rendered while `NavigationStore.viewLevel() === 'system'`,
|
* The HUD's scale ladder marks the level the view is currently at, and offers the others as
|
||||||
* which becomes true only once a full galaxy-to-system camera-flight transition has settled.
|
* buttons. `NavigationStore.viewLevel()` becomes `'system'` only once a full galaxy-to-system
|
||||||
* Bootstrap (data fetch + raycaster wiring) finishes asynchronously after `page.goto()`, so
|
* camera-flight transition has settled, so that marker reading "System" is the signal that the
|
||||||
* this repeatedly clicks the canvas (re-checking first, so it never clicks a system-view body
|
* transition is done.
|
||||||
* marker by accident once the transition has already completed) until the button appears.
|
|
||||||
*/
|
*/
|
||||||
export function backButtonLocator(page: Page): Locator {
|
export function currentLevelLocator(page: Page): Locator {
|
||||||
return page.getByRole('button', { name: 'Galaxy' });
|
return page.getByTestId('hud-current-level');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The ladder entry that takes the view back out of a system, once there is one to leave. */
|
||||||
|
export function backButtonLocator(page: Page): Locator {
|
||||||
|
return page.getByRole('button', { name: 'Solar Neighbourhood' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap (data fetch + raycaster wiring) finishes asynchronously after `page.goto()`, so this
|
||||||
|
* repeatedly clicks the canvas — re-checking first, so it never clicks a system-view body marker
|
||||||
|
* by accident once the transition has already completed — until the view reports it is in a
|
||||||
|
* system.
|
||||||
|
*/
|
||||||
export async function clickCanvasUntilSystemEntered(page: Page, canvas: Locator, backButton: Locator): Promise<void> {
|
export async function clickCanvasUntilSystemEntered(page: Page, canvas: Locator, backButton: Locator): Promise<void> {
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
if (await backButton.isVisible()) {
|
if ((await currentLevelLocator(page).textContent())?.trim() === 'System') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await canvas.click();
|
await canvas.click();
|
||||||
@@ -24,4 +34,5 @@ export async function clickCanvasUntilSystemEntered(page: Page, canvas: Locator,
|
|||||||
{ timeout: 15_000, intervals: [300] }
|
{ timeout: 15_000, intervals: [300] }
|
||||||
)
|
)
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
await expect(backButton).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ import { LabeledPoint } from './star-label-overlay';
|
|||||||
/**
|
/**
|
||||||
* Radius (parsecs) of the shell the backdrop is painted on.
|
* Radius (parsecs) of the shell the backdrop is painted on.
|
||||||
*
|
*
|
||||||
* Chosen to sit clear of everything else the galaxy camera deals with: well outside the 50 pc
|
* Chosen to sit clear of the local star field it is a backdrop for: well outside its 50 pc
|
||||||
* star field, beyond the camera's 2000 pc orbit limit so it can never be flown through, and
|
* radius, and close enough that the far side of the shell stays inside the local view's far
|
||||||
* close enough that even the far side of the shell (2000 + 2500 = 4500 pc) stays inside the
|
* plane rather than being clipped away.
|
||||||
* 5000 pc far plane rather than being clipped away.
|
*
|
||||||
|
* The shell only makes sense from inside it — it is the sky as seen from the Sun, with every
|
||||||
|
* object's true distance flattened onto one radius. The camera can now pull back far past it to
|
||||||
|
* the galactic scale, so {@link DeepSkyRenderer.setStrength} fades it out on the way rather than
|
||||||
|
* letting the view fly through a wall of nebulae.
|
||||||
*/
|
*/
|
||||||
export const BACKDROP_RADIUS_PC = 2500;
|
export const BACKDROP_RADIUS_PC = 2500;
|
||||||
|
|
||||||
@@ -87,6 +91,11 @@ export function deepSkyLabelPoints(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Material keys are `kind:band`; the band is what fixes an object's base opacity. */
|
||||||
|
function bandIndexFromKey(key: string): number {
|
||||||
|
return Number(key.slice(key.indexOf(':') + 1));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paints the notable deep-sky objects from `deepsky.json` onto a fixed shell around the star
|
* Paints the notable deep-sky objects from `deepsky.json` onto a fixed shell around the star
|
||||||
* field, as soft additive billboards coloured by kind and sized by real angular extent.
|
* field, as soft additive billboards coloured by kind and sized by real angular extent.
|
||||||
@@ -121,6 +130,18 @@ export class DeepSkyRenderer {
|
|||||||
return deepSkyLabelPoints(this.records, limit, this.radiusPc);
|
return deepSkyLabelPoints(this.records, limit, this.radiusPc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scales the whole backdrop's opacity, keeping each object's brightness band relative to the
|
||||||
|
* others. Used to dissolve the shell as the camera leaves the neighbourhood it belongs to.
|
||||||
|
*/
|
||||||
|
setStrength(strength: number): void {
|
||||||
|
const clamped = THREE.MathUtils.clamp(strength, 0, 1);
|
||||||
|
for (const [key, material] of this.materials) {
|
||||||
|
material.opacity = BRIGHTNESS_BANDS[bandIndexFromKey(key)].opacity * clamped;
|
||||||
|
}
|
||||||
|
this.object.visible = clamped > 0;
|
||||||
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
for (const material of this.materials.values()) {
|
for (const material of this.materials.values()) {
|
||||||
material.dispose();
|
material.dispose();
|
||||||
|
|||||||
@@ -211,7 +211,11 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
|||||||
const component = fixture.componentInstance as unknown as { galaxyGroup: THREE.Group; systemGroup: THREE.Group };
|
const component = fixture.componentInstance as unknown as { galaxyGroup: THREE.Group; systemGroup: THREE.Group };
|
||||||
expect(component.galaxyGroup.visible).toBe(true);
|
expect(component.galaxyGroup.visible).toBe(true);
|
||||||
expect(component.systemGroup.visible).toBe(false);
|
expect(component.systemGroup.visible).toBe(false);
|
||||||
expect(engine.getCamera().near).toBeCloseTo(0.01, 9);
|
// Parsec-scale rather than an exact figure: in galaxy space the depth range scales with how
|
||||||
|
// far the camera has pulled back, so what identifies it is the far plane it settles on
|
||||||
|
// (5000 pc) versus the AU-space one (20000 AU), not a fixed near plane.
|
||||||
|
expect(engine.getCamera().far).toBeCloseTo(5000, 6);
|
||||||
|
expect(engine.getCamera().near).toBeLessThan(0.1);
|
||||||
expect(navigationStore.viewLevel()).toBe('galaxy');
|
expect(navigationStore.viewLevel()).toBe('galaxy');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -230,6 +234,59 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
|||||||
expect(component.currentStarId).toBe(ALPHA_CENTAURI.id);
|
expect(component.currentStarId).toBe(ALPHA_CENTAURI.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports the galactic scale once the camera has pulled back far enough, and comes back', async () => {
|
||||||
|
const camera = engine.getCamera();
|
||||||
|
|
||||||
|
camera.position.set(0, 0, 30000);
|
||||||
|
await advanceFrames(engine, 0.3);
|
||||||
|
expect(navigationStore.viewLevel()).toBe('galactic');
|
||||||
|
|
||||||
|
camera.position.set(0, 15, 30);
|
||||||
|
await advanceFrames(engine, 0.3);
|
||||||
|
expect(navigationStore.viewLevel()).toBe('galaxy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('widens the depth range as the camera pulls back, instead of holding one range for both scales', async () => {
|
||||||
|
const camera = engine.getCamera();
|
||||||
|
|
||||||
|
await advanceFrames(engine, 0.3);
|
||||||
|
const localFar = camera.far;
|
||||||
|
|
||||||
|
camera.position.set(0, 0, 30000);
|
||||||
|
await advanceFrames(engine, 0.3);
|
||||||
|
|
||||||
|
expect(camera.far).toBeGreaterThan(localFar);
|
||||||
|
// A near plane a hundredth of a parsec out has no precision left to spare at this range.
|
||||||
|
expect(camera.near).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flies out to the Galaxy when the scale ladder asks for it', async () => {
|
||||||
|
const camera = engine.getCamera();
|
||||||
|
fixture.componentInstance.goToLevel('galactic');
|
||||||
|
await advanceFrames(engine, 3);
|
||||||
|
|
||||||
|
expect(camera.position.length()).toBeGreaterThan(10000);
|
||||||
|
expect(navigationStore.viewLevel()).toBe('galactic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the system first when the scale ladder is used from inside one', async () => {
|
||||||
|
navigationStore.selectStar(SUN.id);
|
||||||
|
await flushAsync();
|
||||||
|
await advanceFrames(engine, 2.5);
|
||||||
|
expect(navigationStore.viewLevel()).toBe('system');
|
||||||
|
|
||||||
|
fixture.componentInstance.goToLevel('galactic');
|
||||||
|
await flushAsync();
|
||||||
|
// Exit leg, then the return leg, then the galactic flight: the request has to wait out the
|
||||||
|
// unit-space unwind rather than firing a parsec-scale flight while the scene is in AU.
|
||||||
|
await advanceFrames(engine, 6);
|
||||||
|
|
||||||
|
const component = fixture.componentInstance as unknown as { currentStarId: number | null; systemGroup: THREE.Group };
|
||||||
|
expect(component.currentStarId).toBeNull();
|
||||||
|
expect(component.systemGroup.visible).toBe(false);
|
||||||
|
expect(navigationStore.viewLevel()).toBe('galactic');
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores a new selection while a transition is already in flight, then resolves to the latest requested star once idle', async () => {
|
it('ignores a new selection while a transition is already in flight, then resolves to the latest requested star once idle', async () => {
|
||||||
navigationStore.selectStar(SUN.id);
|
navigationStore.selectStar(SUN.id);
|
||||||
await flushAsync();
|
await flushAsync();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { AfterViewInit, Component, effect, ElementRef, OnDestroy, viewChild } from '@angular/core';
|
import { AfterViewInit, Component, effect, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import * as THREE from 'three/webgpu';
|
import * as THREE from 'three/webgpu';
|
||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
|
||||||
import { dateToJulianDate } from '../../shared/astro/constants';
|
import { dateToJulianDate } from '../../shared/astro/constants';
|
||||||
|
import { galacticCentrePositionPc, galacticToEquatorial, MILKY_WAY_ARMS, SUN_GALACTOCENTRIC_RADIUS_PC } from '../../shared/astro/galaxy';
|
||||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||||
import { EngineService } from '../../core/engine/engine.service';
|
import { EngineService } from '../../core/engine/engine.service';
|
||||||
import { BodyRecord } from '../../shared/models/body.model';
|
import { BodyRecord } from '../../shared/models/body.model';
|
||||||
@@ -12,10 +13,13 @@ import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
|||||||
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
|
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
|
||||||
import { loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
|
import { loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
|
||||||
import { StarRecord } from '../../shared/models/star.model';
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
import { NavigationStore, ViewLevel } from '../../shared/state/navigation.store';
|
||||||
import { CameraRigController } from './camera-rig-controller';
|
import { CameraRigController } from './camera-rig-controller';
|
||||||
import { DeepSkyRenderer } from './deep-sky-renderer';
|
import { DeepSkyRenderer } from './deep-sky-renderer';
|
||||||
|
import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane';
|
||||||
|
import { MilkyWayRenderer } from './milky-way-renderer';
|
||||||
import { starMarkerRadiusAu, SYSTEM_VIEW_DIRECTION, systemFramingDistanceAu } from './system-framing';
|
import { starMarkerRadiusAu, SYSTEM_VIEW_DIRECTION, systemFramingDistanceAu } from './system-framing';
|
||||||
|
import { HudReadout, StarmapHudComponent } from './starmap-hud.component';
|
||||||
import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer';
|
import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer';
|
||||||
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
|
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
|
||||||
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
||||||
@@ -28,6 +32,11 @@ const SUN_GLOW_SCALE = 3.2;
|
|||||||
const LABEL_MAX_DISTANCE_PC = 20;
|
const LABEL_MAX_DISTANCE_PC = 20;
|
||||||
/** Caps how many labels are shown at once, to keep the DOM light. */
|
/** Caps how many labels are shown at once, to keep the DOM light. */
|
||||||
const LABEL_MAX_COUNT = 15;
|
const LABEL_MAX_COUNT = 15;
|
||||||
|
/**
|
||||||
|
* Minimum on-screen separation between two labels, in NDC (roughly 6% of the viewport height).
|
||||||
|
* Nearer stars win the space; see `spreadLabels`.
|
||||||
|
*/
|
||||||
|
const LABEL_MIN_SEPARATION_NDC = 0.12;
|
||||||
/**
|
/**
|
||||||
* How many deep-sky objects get a permanent label. These sit on a fixed backdrop shell rather
|
* How many deep-sky objects get a permanent label. These sit on a fixed backdrop shell rather
|
||||||
* than near the camera, so proximity is meaningless for them — the brightest handful are simply
|
* than near the camera, so proximity is meaningless for them — the brightest handful are simply
|
||||||
@@ -39,15 +48,53 @@ const LABEL_UPDATE_INTERVAL_SECONDS = 0.2;
|
|||||||
/** Pointer travel (px) above which a press counts as an orbit drag rather than a selection. */
|
/** Pointer travel (px) above which a press counts as an orbit drag rather than a selection. */
|
||||||
const CLICK_DRAG_SLOP_PX = 5;
|
const CLICK_DRAG_SLOP_PX = 5;
|
||||||
|
|
||||||
const GALAXY_OVERVIEW_POSITION = new THREE.Vector3(0, 15, 30);
|
/**
|
||||||
|
* Opening pose for the local view, expressed in the galactic frame rather than the equatorial
|
||||||
|
* one: about 35 degrees above the galactic plane, looking down at the Sun. Picked so the grid
|
||||||
|
* reads as a floor under the star field instead of slicing across it edge-on, which is what an
|
||||||
|
* arbitrary equatorial direction gives — the plane is tilted 63 degrees to the equator.
|
||||||
|
*/
|
||||||
|
const GALAXY_OVERVIEW_POSITION = (() => {
|
||||||
|
const view = galacticToEquatorial({ x: -21, y: -46, z: 35 });
|
||||||
|
return new THREE.Vector3(view.x, view.y, view.z);
|
||||||
|
})();
|
||||||
const GALAXY_OVERVIEW_TARGET = new THREE.Vector3(0, 0, 0);
|
const GALAXY_OVERVIEW_TARGET = new THREE.Vector3(0, 0, 0);
|
||||||
const GALAXY_NEAR_PC = 0.01;
|
const GALAXY_NEAR_PC = 0.01;
|
||||||
const GALAXY_FAR_PC = 5000;
|
const GALAXY_FAR_PC = 5000;
|
||||||
const GALAXY_MIN_DISTANCE_PC = 0.5;
|
const GALAXY_MIN_DISTANCE_PC = 0.5;
|
||||||
const GALAXY_MAX_DISTANCE_PC = 2000;
|
/** Far enough out to hold the whole Galaxy in frame; the near/far planes swap to match. */
|
||||||
|
const GALAXY_MAX_DISTANCE_PC = 70000;
|
||||||
/** How close (pc) the camera dives toward a selected star before the unit-space swap. */
|
/** How close (pc) the camera dives toward a selected star before the unit-space swap. */
|
||||||
const GALAXY_APPROACH_DISTANCE_PC = 0.05;
|
const GALAXY_APPROACH_DISTANCE_PC = 0.05;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Depth range for the galactic scale. The local view needs a 1-centimetre-of-a-parsec near
|
||||||
|
* plane to fly into a star; the galactic view needs a far plane a hundred thousand parsecs out.
|
||||||
|
* Asking one projection to span both would leave the depth buffer with nothing left to
|
||||||
|
* distinguish two arms with. They swap at the crossfade instead, which happens while the camera
|
||||||
|
* is hundreds of parsecs from anything and so is invisible.
|
||||||
|
*/
|
||||||
|
const GALACTIC_NEAR_PC = 5;
|
||||||
|
const GALACTIC_FAR_PC = 250000;
|
||||||
|
|
||||||
|
/** Rings for the local grid (parsecs from the Sun), with the catalogue's edge called out. */
|
||||||
|
const LOCAL_GRID_RINGS_PC = [10, 20, 30, 40, 50];
|
||||||
|
const LOCAL_GRID_SPOKES = 12;
|
||||||
|
/** Rings for the galactic grid (parsecs from the centre), with the Sun's orbit called out. */
|
||||||
|
const GALACTIC_GRID_RINGS_PC = [2500, 5000, SUN_GALACTOCENTRIC_RADIUS_PC, 11000, 14000];
|
||||||
|
const GALACTIC_GRID_SPOKES = 24;
|
||||||
|
/** The local grid passes through the Sun, which is the origin, so tethers drop to height zero. */
|
||||||
|
const LOCAL_PLANE_HEIGHT_PC = 0;
|
||||||
|
/** How many of the Sun's nearest neighbours get a permanent drop line to the local grid. */
|
||||||
|
const TETHERED_STAR_COUNT = 28;
|
||||||
|
|
||||||
|
/** Camera pose for the whole-Galaxy overview: above the disc, out past the Sun, looking in. */
|
||||||
|
const GALACTIC_OVERVIEW_HEIGHT_PC = 26000;
|
||||||
|
const GALACTIC_OVERVIEW_BACK_PC = 11000;
|
||||||
|
|
||||||
|
/** Above this share of the Galaxy-model crossfade, the HUD calls the view galactic. */
|
||||||
|
const GALACTIC_LEVEL_THRESHOLD = 0.5;
|
||||||
|
|
||||||
const SYSTEM_NEAR_AU = 0.002;
|
const SYSTEM_NEAR_AU = 0.002;
|
||||||
const SYSTEM_FAR_AU = 20000;
|
const SYSTEM_FAR_AU = 20000;
|
||||||
const SYSTEM_MIN_DISTANCE_AU = 0.05;
|
const SYSTEM_MIN_DISTANCE_AU = 0.05;
|
||||||
@@ -61,6 +108,29 @@ const APPROACH_DURATION_SECONDS = 1.0;
|
|||||||
const SETTLE_DURATION_SECONDS = 0.9;
|
const SETTLE_DURATION_SECONDS = 0.9;
|
||||||
const EXIT_DURATION_SECONDS = 0.9;
|
const EXIT_DURATION_SECONDS = 0.9;
|
||||||
const RETURN_DURATION_SECONDS = 1.1;
|
const RETURN_DURATION_SECONDS = 1.1;
|
||||||
|
const GALACTIC_FLIGHT_SECONDS = 2.4;
|
||||||
|
|
||||||
|
/** Camera range for the readout panel, in the unit that suits the distance. */
|
||||||
|
function formatParsecs(distancePc: number): string {
|
||||||
|
return distancePc >= 1000 ? `${(distancePc / 1000).toFixed(1)} kpc` : `${distancePc.toFixed(distancePc < 10 ? 2 : 0)} pc`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAu(distanceAu: number): string {
|
||||||
|
return distanceAu >= 100 ? `${distanceAu.toFixed(0)} AU` : `${distanceAu.toFixed(2)} AU`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the camera sits to hold the whole Galaxy: above the disc and back past the Sun, looking
|
||||||
|
* at the centre — near enough to the angle the Galaxy is usually drawn from, and it keeps the
|
||||||
|
* Sun between the camera and the centre so "you are here" stays legible.
|
||||||
|
*/
|
||||||
|
function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector3 } {
|
||||||
|
const centre = galacticCentrePositionPc();
|
||||||
|
const target = new THREE.Vector3(centre.x, centre.y, centre.z);
|
||||||
|
const awayFromCentre = target.clone().negate().normalize();
|
||||||
|
const position = target.clone().add(galacticNormal().multiplyScalar(GALACTIC_OVERVIEW_HEIGHT_PC)).add(awayFromCentre.multiplyScalar(GALACTIC_OVERVIEW_BACK_PC));
|
||||||
|
return { position, target };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hosts the shared galaxy + system scene: pan/zoom/rotate camera controls, click-to-select
|
* Hosts the shared galaxy + system scene: pan/zoom/rotate camera controls, click-to-select
|
||||||
@@ -72,22 +142,21 @@ const RETURN_DURATION_SECONDS = 1.1;
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-galaxy-system-scene',
|
selector: 'app-galaxy-system-scene',
|
||||||
providers: [EngineService],
|
providers: [EngineService],
|
||||||
|
imports: [StarmapHudComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="relative h-full w-full">
|
<div class="relative h-full w-full">
|
||||||
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
|
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
|
||||||
<div #labelHost class="absolute inset-0 overflow-hidden pointer-events-none"></div>
|
<div #labelHost class="absolute inset-0 overflow-hidden pointer-events-none"></div>
|
||||||
@if (navigationStore.viewLevel() === 'system') {
|
<app-starmap-hud
|
||||||
<button
|
[level]="navigationStore.viewLevel()"
|
||||||
type="button"
|
[eyebrow]="hudEyebrow()"
|
||||||
(click)="exitSystem()"
|
[title]="hudTitle()"
|
||||||
class="absolute top-4 left-4 flex items-center gap-1.5 rounded-md border border-border bg-panel/70 px-3 py-1.5 font-body text-xs tracking-wide text-muted uppercase backdrop-blur-md transition-colors hover:border-accent hover:text-accent focus:outline-none focus:ring-1 focus:ring-accent/50"
|
[subtitle]="hudSubtitle()"
|
||||||
>
|
[readouts]="hudReadouts()"
|
||||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
[note]="hudNote()"
|
||||||
<path d="M15 6l-6 6 6 6" />
|
[range]="hudRange()"
|
||||||
</svg>
|
(levelSelected)="goToLevel($event)"
|
||||||
Galaxy
|
/>
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
@@ -102,11 +171,26 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
/** Rebuilt per system, since the star's radius is derived from that system's innermost orbit. */
|
/** Rebuilt per system, since the star's radius is derived from that system's innermost orbit. */
|
||||||
private starMarkerGeometry?: THREE.SphereGeometry;
|
private starMarkerGeometry?: THREE.SphereGeometry;
|
||||||
|
|
||||||
|
/** Readout panel contents, refreshed on the same cadence as the labels rather than per frame. */
|
||||||
|
readonly hudEyebrow = signal('');
|
||||||
|
readonly hudTitle = signal('');
|
||||||
|
readonly hudSubtitle = signal('');
|
||||||
|
readonly hudReadouts = signal<readonly HudReadout[]>([]);
|
||||||
|
readonly hudNote = signal('');
|
||||||
|
readonly hudRange = signal('');
|
||||||
|
|
||||||
private controls?: OrbitControls;
|
private controls?: OrbitControls;
|
||||||
private rig?: CameraRigController;
|
private rig?: CameraRigController;
|
||||||
private starField?: StarFieldRenderer;
|
private starField?: StarFieldRenderer;
|
||||||
private deepSky?: DeepSkyRenderer;
|
private deepSky?: DeepSkyRenderer;
|
||||||
private deepSkyLabels: readonly LabeledPoint[] = [];
|
private deepSkyLabels: readonly LabeledPoint[] = [];
|
||||||
|
private milkyWay?: MilkyWayRenderer;
|
||||||
|
private galacticLabels: readonly LabeledPoint[] = [];
|
||||||
|
private galacticGrid?: PolarGridPlane;
|
||||||
|
private localGrid?: PolarGridPlane;
|
||||||
|
private tethers?: TetherField;
|
||||||
|
/** Strength of the Galaxy-model crossfade, 0 (local view) to 1 (galactic view). */
|
||||||
|
private galacticStrength = 0;
|
||||||
private labelOverlay?: StarLabelOverlay;
|
private labelOverlay?: StarLabelOverlay;
|
||||||
private stars: readonly StarRecord[] = [];
|
private stars: readonly StarRecord[] = [];
|
||||||
private starsById = new Map<number, StarRecord>();
|
private starsById = new Map<number, StarRecord>();
|
||||||
@@ -118,6 +202,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
private pointerDownAt: { x: number; y: number } | null = null;
|
private pointerDownAt: { x: number; y: number } | null = null;
|
||||||
private ready = false;
|
private ready = false;
|
||||||
private busy = false;
|
private busy = false;
|
||||||
|
/** Scale the HUD asked for while a system transition was still unwinding. */
|
||||||
|
private pendingLevel: ViewLevel | null = null;
|
||||||
|
|
||||||
/** Id of the star whose system is currently shown (or being flown to/from); null = galaxy view. */
|
/** Id of the star whose system is currently shown (or being flown to/from); null = galaxy view. */
|
||||||
private currentStarId: number | null = null;
|
private currentStarId: number | null = null;
|
||||||
@@ -151,6 +237,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.controls?.dispose();
|
this.controls?.dispose();
|
||||||
this.starField?.dispose();
|
this.starField?.dispose();
|
||||||
this.deepSky?.dispose();
|
this.deepSky?.dispose();
|
||||||
|
this.milkyWay?.dispose();
|
||||||
|
this.galacticGrid?.dispose();
|
||||||
|
this.localGrid?.dispose();
|
||||||
|
this.tethers?.dispose();
|
||||||
this.labelOverlay?.dispose();
|
this.labelOverlay?.dispose();
|
||||||
this.systemRenderer?.dispose();
|
this.systemRenderer?.dispose();
|
||||||
(this.starMarker?.material as THREE.Material | undefined)?.dispose();
|
(this.starMarker?.material as THREE.Material | undefined)?.dispose();
|
||||||
@@ -160,8 +250,34 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.engine.dispose();
|
this.engine.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
exitSystem(): void {
|
/**
|
||||||
|
* Moves the view to a wider scale, from the HUD's scale ladder.
|
||||||
|
*
|
||||||
|
* The two outer levels are one continuous space, so "go to the Milky Way" is a camera flight
|
||||||
|
* rather than a scene change. Leaving a system is not: it has to unwind the unit-space swap
|
||||||
|
* first, so a request made from inside a system is parked until the exit flight lands.
|
||||||
|
*/
|
||||||
|
goToLevel(level: ViewLevel): void {
|
||||||
|
if (level === 'system') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentStarId !== null || this.busy) {
|
||||||
|
this.pendingLevel = level;
|
||||||
this.navigationStore.selectStar(null);
|
this.navigationStore.selectStar(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.flyToOverview(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
private flyToOverview(level: ViewLevel): void {
|
||||||
|
if (!this.rig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pose = level === 'galactic' ? galacticOverviewPose() : { position: GALAXY_OVERVIEW_POSITION.clone(), target: GALAXY_OVERVIEW_TARGET.clone() };
|
||||||
|
// The galactic flight covers four orders of magnitude, so it gets longer than a local hop.
|
||||||
|
this.rig.flyTo(pose, level === 'galactic' ? GALACTIC_FLIGHT_SECONDS : RETURN_DURATION_SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async bootstrap(): Promise<void> {
|
private async bootstrap(): Promise<void> {
|
||||||
@@ -212,6 +328,33 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.starField = new StarFieldRenderer(stars, positions);
|
this.starField = new StarFieldRenderer(stars, positions);
|
||||||
this.galaxyGroup.add(this.starField.object);
|
this.galaxyGroup.add(this.starField.object);
|
||||||
|
|
||||||
|
this.milkyWay = new MilkyWayRenderer();
|
||||||
|
this.galacticLabels = this.milkyWay.labelPoints();
|
||||||
|
const centre = galacticCentrePositionPc();
|
||||||
|
this.galacticGrid = new PolarGridPlane({
|
||||||
|
ringRadiiPc: GALACTIC_GRID_RINGS_PC,
|
||||||
|
spokeCount: GALACTIC_GRID_SPOKES,
|
||||||
|
centrePc: new THREE.Vector3(centre.x, centre.y, centre.z),
|
||||||
|
emphasisRadiiPc: [SUN_GALACTOCENTRIC_RADIUS_PC]
|
||||||
|
});
|
||||||
|
this.localGrid = new PolarGridPlane({
|
||||||
|
ringRadiiPc: LOCAL_GRID_RINGS_PC,
|
||||||
|
spokeCount: LOCAL_GRID_SPOKES,
|
||||||
|
emphasisRadiiPc: [LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]]
|
||||||
|
});
|
||||||
|
// Drop lines for the Sun's nearest neighbours. A fixed set rather than whatever is currently
|
||||||
|
// labelled: these are the stars the local view is about, they cluster where the grid is
|
||||||
|
// densest, and a tether that appears and vanishes as the camera drifts reads as a glitch.
|
||||||
|
this.tethers = new TetherField(TETHERED_STAR_COUNT);
|
||||||
|
this.tethers.setTargets(
|
||||||
|
[...stars]
|
||||||
|
.sort((a, b) => Math.hypot(a.x, a.y, a.z) - Math.hypot(b.x, b.y, b.z))
|
||||||
|
.slice(0, TETHERED_STAR_COUNT)
|
||||||
|
.map((star) => new THREE.Vector3(star.x, star.y, star.z)),
|
||||||
|
LOCAL_PLANE_HEIGHT_PC
|
||||||
|
);
|
||||||
|
this.galaxyGroup.add(this.milkyWay.object, this.galacticGrid.object, this.localGrid.object, this.tethers.object);
|
||||||
|
|
||||||
if (deepSky.length > 0) {
|
if (deepSky.length > 0) {
|
||||||
this.deepSky = new DeepSkyRenderer(deepSky);
|
this.deepSky = new DeepSkyRenderer(deepSky);
|
||||||
this.galaxyGroup.add(this.deepSky.object);
|
this.galaxyGroup.add(this.deepSky.object);
|
||||||
@@ -243,11 +386,18 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
// `currentStarId` is still null, so labels were being recomputed from galaxy-scale positions
|
// `currentStarId` is still null, so labels were being recomputed from galaxy-scale positions
|
||||||
// and pinned over the system — the whole point of clearing them on the swap.
|
// and pinned over the system — the whole point of clearing them on the swap.
|
||||||
if (this.galaxyGroup.visible) {
|
if (this.galaxyGroup.visible) {
|
||||||
|
// Per-frame, unlike the labels: this is a handful of uniform writes, and it is what keeps
|
||||||
|
// the zoom continuous rather than stepping between two discrete scales.
|
||||||
|
this.updateGalacticCrossfade(camera);
|
||||||
|
}
|
||||||
|
|
||||||
this.labelUpdateAccumulator += deltaSeconds;
|
this.labelUpdateAccumulator += deltaSeconds;
|
||||||
if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) {
|
if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) {
|
||||||
this.labelUpdateAccumulator = 0;
|
this.labelUpdateAccumulator = 0;
|
||||||
|
if (this.galaxyGroup.visible) {
|
||||||
this.updateLabels(camera);
|
this.updateLabels(camera);
|
||||||
}
|
}
|
||||||
|
this.updateHud(camera);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.systemGroup.visible) {
|
if (this.systemGroup.visible) {
|
||||||
@@ -256,9 +406,60 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.labelOverlay?.render(camera);
|
this.labelOverlay?.render(camera);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blends between the two things that share parsec space: the catalogued 50 pc star field with
|
||||||
|
* its local grid, and the Milky Way model with its galactic one. Driven by how far the camera
|
||||||
|
* has pulled back from the Sun, so the scale ladder reports where the view already is instead
|
||||||
|
* of switching it.
|
||||||
|
*/
|
||||||
|
private updateGalacticCrossfade(camera: THREE.PerspectiveCamera): void {
|
||||||
|
if (!this.milkyWay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distancePc = camera.position.length();
|
||||||
|
this.galacticStrength = this.milkyWay.setViewerDistancePc(distancePc);
|
||||||
|
|
||||||
|
this.galacticGrid?.setStrength(this.galacticStrength);
|
||||||
|
this.localGrid?.setStrength(1 - this.galacticStrength);
|
||||||
|
this.tethers?.setStrength(1 - this.galacticStrength);
|
||||||
|
// The backdrop shell is the sky as seen from here; from outside it, it is a wall.
|
||||||
|
this.deepSky?.setStrength(1 - this.galacticStrength);
|
||||||
|
// Same argument for the skybox, and more sharply: it is a photograph of the Milky Way taken
|
||||||
|
// from inside it, so it cannot also be the sky behind a view of the Galaxy from outside.
|
||||||
|
this.engine.getScene().backgroundIntensity = 1 - this.galacticStrength;
|
||||||
|
|
||||||
|
this.applyGalaxyDepthRange(camera, distancePc);
|
||||||
|
const level: ViewLevel = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD ? 'galactic' : 'galaxy';
|
||||||
|
if (this.navigationStore.viewLevel() !== level && !this.systemGroup.visible) {
|
||||||
|
this.navigationStore.setViewLevel(level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the depth range proportional to how far out the camera is. One fixed pair cannot serve
|
||||||
|
* both ends of this view: flying into a star needs a near plane a hundredth of a parsec out,
|
||||||
|
* and holding the Galaxy needs a far plane a hundred thousand parsecs out, and a projection
|
||||||
|
* spanning both has no precision left to separate one spiral arm from the next.
|
||||||
|
*/
|
||||||
|
private applyGalaxyDepthRange(camera: THREE.PerspectiveCamera, distancePc: number): void {
|
||||||
|
const near = THREE.MathUtils.clamp(distancePc / 2000, GALAXY_NEAR_PC, GALACTIC_NEAR_PC);
|
||||||
|
const far = THREE.MathUtils.clamp(distancePc * 8, GALAXY_FAR_PC, GALACTIC_FAR_PC);
|
||||||
|
// Only when it has drifted enough to matter, so a slow zoom isn't rebuilding the projection
|
||||||
|
// matrix on every frame of it.
|
||||||
|
if (Math.abs(near - camera.near) > camera.near * 0.05 || Math.abs(far - camera.far) > camera.far * 0.05) {
|
||||||
|
camera.near = near;
|
||||||
|
camera.far = far;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private updateLabels(camera: THREE.PerspectiveCamera): void {
|
private updateLabels(camera: THREE.PerspectiveCamera): void {
|
||||||
const selectedId = this.navigationStore.selectedStarId();
|
const selectedId = this.navigationStore.selectedStarId();
|
||||||
const { x: cx, y: cy, z: cz } = camera.position;
|
// Measured from what the camera is looking at, not from where it is. Those differ by the
|
||||||
|
// orbit distance, so a camera-relative rule names the stars closest to the near edge of the
|
||||||
|
// view — a ring of labels around the outside of the thing the user is actually looking at.
|
||||||
|
const { x: cx, y: cy, z: cz } = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
|
||||||
const maxDistanceSq = LABEL_MAX_DISTANCE_PC * LABEL_MAX_DISTANCE_PC;
|
const maxDistanceSq = LABEL_MAX_DISTANCE_PC * LABEL_MAX_DISTANCE_PC;
|
||||||
|
|
||||||
const candidates: Array<{ star: StarRecord; distanceSq: number }> = [];
|
const candidates: Array<{ star: StarRecord; distanceSq: number }> = [];
|
||||||
@@ -273,8 +474,96 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
candidates.sort((a, b) => a.distanceSq - b.distanceSq);
|
candidates.sort((a, b) => a.distanceSq - b.distanceSq);
|
||||||
const starLabels = candidates.slice(0, LABEL_MAX_COUNT).map((candidate) => candidate.star);
|
// Individual star names mean nothing once the whole Galaxy is in frame — at that range the
|
||||||
this.labelOverlay?.update([...starLabels, ...this.deepSkyLabels]);
|
// entire catalogue is inside one pixel — so the labels hand over to the structural ones.
|
||||||
|
const isGalactic = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD;
|
||||||
|
const starLabels = isGalactic ? [] : this.spreadLabels(candidates, camera, selectedId);
|
||||||
|
const backdropLabels = isGalactic ? this.galacticLabels : this.deepSkyLabels;
|
||||||
|
this.labelOverlay?.update([...starLabels, ...backdropLabels]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes the nearest stars in order and keeps only those that land clear of the labels already
|
||||||
|
* placed, dropping the rest.
|
||||||
|
*
|
||||||
|
* Nearest-first alone is not enough: the Sun's fifteen nearest neighbours are all inside four
|
||||||
|
* parsecs, so from anything but point-blank range their names print on top of each other in a
|
||||||
|
* single unreadable clump. Rejecting on screen separation instead of on distance means the set
|
||||||
|
* naturally opens up as the camera closes in, and stays legible when it pulls back.
|
||||||
|
*/
|
||||||
|
private spreadLabels(candidates: readonly { star: StarRecord }[], camera: THREE.PerspectiveCamera, selectedId: number | null): StarRecord[] {
|
||||||
|
const placed: THREE.Vector2[] = [];
|
||||||
|
const chosen: StarRecord[] = [];
|
||||||
|
const projected = new THREE.Vector3();
|
||||||
|
|
||||||
|
for (const { star } of candidates) {
|
||||||
|
if (chosen.length >= LABEL_MAX_COUNT) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
projected.set(star.x, star.y, star.z).project(camera);
|
||||||
|
const isSelected = star.id === selectedId;
|
||||||
|
// Offscreen or behind the camera. The selection is exempt: it is about to be flown to, and
|
||||||
|
// its label going missing mid-flight reads as the target having been lost.
|
||||||
|
if (!isSelected && (projected.z < -1 || projected.z > 1 || Math.abs(projected.x) > 1 || Math.abs(projected.y) > 1)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = new THREE.Vector2(projected.x * camera.aspect, projected.y);
|
||||||
|
if (!isSelected && placed.some((other) => other.distanceTo(point) < LABEL_MIN_SEPARATION_NDC)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
placed.push(point);
|
||||||
|
chosen.push(star);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chosen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refreshes the readout panel for whichever scale the view is currently at. */
|
||||||
|
private updateHud(camera: THREE.PerspectiveCamera): void {
|
||||||
|
const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId);
|
||||||
|
|
||||||
|
if (this.systemGroup.visible && star) {
|
||||||
|
const planetCount = this.bodies.filter((body) => body.systemStarId === star.id && !body.parentBodyId).length + this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id).length;
|
||||||
|
this.hudEyebrow.set('System');
|
||||||
|
this.hudTitle.set(star.name);
|
||||||
|
this.hudSubtitle.set(star.spectralType ? `Spectral type ${star.spectralType}` : '');
|
||||||
|
this.hudReadouts.set([
|
||||||
|
{ label: 'Bodies', value: `${planetCount}` },
|
||||||
|
{ label: 'Distance', value: `${Math.hypot(star.x, star.y, star.z).toFixed(2)} pc` },
|
||||||
|
{ label: 'Magnitude', value: star.magnitude.toFixed(2) }
|
||||||
|
]);
|
||||||
|
this.hudNote.set('Orbits propagated from published elements to the current date.');
|
||||||
|
this.hudRange.set(formatAu(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hudRange.set(formatParsecs(camera.position.length()));
|
||||||
|
|
||||||
|
if (this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) {
|
||||||
|
this.hudEyebrow.set('Galactic Scale');
|
||||||
|
this.hudTitle.set('Milky Way');
|
||||||
|
this.hudSubtitle.set('Barred spiral galaxy · our own');
|
||||||
|
this.hudReadouts.set([
|
||||||
|
{ label: 'Sun to centre', value: `${(SUN_GALACTOCENTRIC_RADIUS_PC / 1000).toFixed(2)} kpc` },
|
||||||
|
{ label: 'Arms modelled', value: `${MILKY_WAY_ARMS.length}` },
|
||||||
|
{ label: 'Catalogued', value: `${this.stars.length} stars` }
|
||||||
|
]);
|
||||||
|
this.hudNote.set('Galactic structure is an illustrative model built on measured arm geometry — no catalogue holds the Galaxy’s stars. Everything inside 50 pc is real.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hudEyebrow.set('Solar Neighbourhood');
|
||||||
|
this.hudTitle.set('Local Stars');
|
||||||
|
this.hudSubtitle.set('Hipparcos · Yale Bright Star · Gliese');
|
||||||
|
this.hudReadouts.set([
|
||||||
|
{ label: 'Stars', value: `${this.stars.length}` },
|
||||||
|
{ label: 'Radius', value: `${LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]} pc` },
|
||||||
|
{ label: 'Exoplanets', value: `${this.exoplanets.length}` }
|
||||||
|
]);
|
||||||
|
this.hudNote.set('Positions from measured parallaxes. Grid marks the galactic plane through the Sun.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where the current press started, so a drag can be told apart from a click. */
|
/** Where the current press started, so a drag can be told apart from a click. */
|
||||||
@@ -354,6 +643,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
private finishTransition(): void {
|
private finishTransition(): void {
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
this.reconcileSelection(this.navigationStore.selectedStarId());
|
this.reconcileSelection(this.navigationStore.selectedStarId());
|
||||||
|
|
||||||
|
// Only once the scene is settled back in parsec space can a scale request be honoured.
|
||||||
|
const pending = this.pendingLevel;
|
||||||
|
this.pendingLevel = null;
|
||||||
|
if (pending && !this.busy && this.currentStarId === null) {
|
||||||
|
this.flyToOverview(pending);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enterSystem(starId: number, onComplete: () => void): void {
|
private enterSystem(starId: number, onComplete: () => void): void {
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import * as THREE from 'three/webgpu';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { galacticCentrePositionPc, SUN_HEIGHT_ABOVE_MIDPLANE_PC } from '../../shared/astro/galaxy';
|
||||||
|
import { galacticFrameQuaternion, galacticNormal, PolarGridPlane, TetherField } from './grid-plane';
|
||||||
|
|
||||||
|
const SEGMENTS_PER_RING = 180;
|
||||||
|
|
||||||
|
function vertexAt(geometry: THREE.BufferGeometry, index: number): THREE.Vector3 {
|
||||||
|
const position = geometry.getAttribute('position');
|
||||||
|
return new THREE.Vector3(position.getX(index), position.getY(index), position.getZ(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('galacticFrameQuaternion', () => {
|
||||||
|
it('carries the local +Z onto the galactic normal, so a flat grid lands in the galactic plane', () => {
|
||||||
|
const rotated = new THREE.Vector3(0, 0, 1).applyQuaternion(galacticFrameQuaternion());
|
||||||
|
const normal = galacticNormal();
|
||||||
|
expect(rotated.x).toBeCloseTo(normal.x, 9);
|
||||||
|
expect(rotated.y).toBeCloseTo(normal.y, 9);
|
||||||
|
expect(rotated.z).toBeCloseTo(normal.z, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tilts that plane the real angle away from the celestial equator', () => {
|
||||||
|
// The galactic and celestial poles are 62.9 degrees apart, so the planes are too.
|
||||||
|
const normal = galacticNormal();
|
||||||
|
expect((Math.acos(Math.abs(normal.z)) * 180) / Math.PI).toBeCloseTo(62.87, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PolarGridPlane', () => {
|
||||||
|
const rings = [10, 20, 50];
|
||||||
|
const spokes = 8;
|
||||||
|
const grid = new PolarGridPlane({ ringRadiiPc: rings, spokeCount: spokes, emphasisRadiiPc: [50] });
|
||||||
|
|
||||||
|
it('draws every ring segment and every spoke', () => {
|
||||||
|
expect(grid.object.geometry.getAttribute('position').count).toBe(rings.length * SEGMENTS_PER_RING * 2 + spokes * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts hidden, so a view that never zooms out never draws it', () => {
|
||||||
|
expect(grid.object.visible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fades in and out with strength, and disappears outright at zero', () => {
|
||||||
|
grid.setStrength(1);
|
||||||
|
expect(grid.object.visible).toBe(true);
|
||||||
|
const full = (grid.object.material as THREE.LineBasicMaterial).opacity;
|
||||||
|
|
||||||
|
grid.setStrength(0.5);
|
||||||
|
expect((grid.object.material as THREE.LineBasicMaterial).opacity).toBeCloseTo(full / 2, 6);
|
||||||
|
|
||||||
|
grid.setStrength(0);
|
||||||
|
expect(grid.object.visible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps strength rather than letting opacity run past one', () => {
|
||||||
|
grid.setStrength(4);
|
||||||
|
expect((grid.object.material as THREE.LineBasicMaterial).opacity).toBeLessThanOrEqual(1);
|
||||||
|
grid.setStrength(-1);
|
||||||
|
expect(grid.object.visible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lies in the galactic plane through its centre once placed in the scene', () => {
|
||||||
|
grid.object.updateMatrixWorld(true);
|
||||||
|
const normal = galacticNormal();
|
||||||
|
|
||||||
|
for (const index of [0, 100, 1000, grid.object.geometry.getAttribute('position').count - 1]) {
|
||||||
|
const world = vertexAt(grid.object.geometry, index).applyMatrix4(grid.object.matrixWorld);
|
||||||
|
expect(world.dot(normal)).toBeCloseTo(0, 6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sits on the galactic centre when given it, still in the plane', () => {
|
||||||
|
const centre = galacticCentrePositionPc();
|
||||||
|
const galacticGrid = new PolarGridPlane({
|
||||||
|
ringRadiiPc: [2500, 8178],
|
||||||
|
spokeCount: 4,
|
||||||
|
centrePc: new THREE.Vector3(centre.x, centre.y, centre.z)
|
||||||
|
});
|
||||||
|
galacticGrid.object.updateMatrixWorld(true);
|
||||||
|
|
||||||
|
const normal = galacticNormal();
|
||||||
|
const world = vertexAt(galacticGrid.object.geometry, 0).applyMatrix4(galacticGrid.object.matrixWorld);
|
||||||
|
// The centre is one Sun-height below the Sun's own plane, and the grid follows it there.
|
||||||
|
expect(world.dot(normal)).toBeCloseTo(-SUN_HEIGHT_ABOVE_MIDPLANE_PC, 4);
|
||||||
|
|
||||||
|
galacticGrid.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the emphasised ring brighter than the rest', () => {
|
||||||
|
const colors = grid.object.geometry.getAttribute('color');
|
||||||
|
// Vertices are written ring by ring, in the order they were listed: 10 pc first, 50 pc last.
|
||||||
|
const innerBrightness = colors.getX(0) + colors.getY(0) + colors.getZ(0);
|
||||||
|
const emphasisIndex = 2 * SEGMENTS_PER_RING * 2;
|
||||||
|
const emphasisBrightness = colors.getX(emphasisIndex) + colors.getY(emphasisIndex) + colors.getZ(emphasisIndex);
|
||||||
|
expect(emphasisBrightness).toBeGreaterThan(innerBrightness);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TetherField', () => {
|
||||||
|
it('drops each point onto the plane, straight down the galactic normal', () => {
|
||||||
|
const field = new TetherField(4);
|
||||||
|
const point = new THREE.Vector3(12, -7, 30);
|
||||||
|
field.setTargets([point]);
|
||||||
|
|
||||||
|
const geometry = field.object.geometry;
|
||||||
|
const top = vertexAt(geometry, 0);
|
||||||
|
const foot = vertexAt(geometry, 1);
|
||||||
|
const normal = galacticNormal();
|
||||||
|
|
||||||
|
expect(top.distanceTo(point)).toBeCloseTo(0, 4);
|
||||||
|
// The foot is in the plane...
|
||||||
|
expect(foot.dot(normal)).toBeCloseTo(0, 4);
|
||||||
|
// ...and directly below the point: the drop has no sideways component.
|
||||||
|
const drop = top.clone().sub(foot);
|
||||||
|
expect(drop.clone().cross(normal).length()).toBeCloseTo(0, 4);
|
||||||
|
|
||||||
|
field.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops onto an offset plane when asked, for a grid on the true midplane', () => {
|
||||||
|
const field = new TetherField(2);
|
||||||
|
field.setTargets([new THREE.Vector3(0, 0, 100)], -SUN_HEIGHT_ABOVE_MIDPLANE_PC);
|
||||||
|
|
||||||
|
const foot = vertexAt(field.object.geometry, 1);
|
||||||
|
expect(foot.dot(galacticNormal())).toBeCloseTo(-SUN_HEIGHT_ABOVE_MIDPLANE_PC, 4);
|
||||||
|
|
||||||
|
field.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws two vertices per tether and nothing for the ones it was not given', () => {
|
||||||
|
const field = new TetherField(8);
|
||||||
|
field.setTargets([new THREE.Vector3(1, 2, 3), new THREE.Vector3(4, 5, 6)]);
|
||||||
|
expect(field.object.geometry.drawRange.count).toBe(4);
|
||||||
|
|
||||||
|
field.setTargets([]);
|
||||||
|
expect(field.object.geometry.drawRange.count).toBe(0);
|
||||||
|
|
||||||
|
field.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops points past its capacity rather than overrunning the buffer', () => {
|
||||||
|
const field = new TetherField(2);
|
||||||
|
const points = [new THREE.Vector3(1, 0, 5), new THREE.Vector3(2, 0, 5), new THREE.Vector3(3, 0, 5), new THREE.Vector3(4, 0, 5)];
|
||||||
|
expect(() => field.setTargets(points)).not.toThrow();
|
||||||
|
expect(field.object.geometry.drawRange.count).toBe(4);
|
||||||
|
expect(field.object.geometry.getAttribute('position').count).toBe(4);
|
||||||
|
|
||||||
|
field.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays hidden until it is given a strength', () => {
|
||||||
|
const field = new TetherField(2);
|
||||||
|
expect(field.object.visible).toBe(false);
|
||||||
|
|
||||||
|
field.setStrength(1);
|
||||||
|
expect(field.object.visible).toBe(true);
|
||||||
|
field.setStrength(0);
|
||||||
|
expect(field.object.visible).toBe(false);
|
||||||
|
|
||||||
|
field.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import * as THREE from 'three/webgpu';
|
||||||
|
|
||||||
|
import { GALACTIC_BASIS_EQUATORIAL, SUN_HEIGHT_ABOVE_MIDPLANE_PC } from '../../shared/astro/galaxy';
|
||||||
|
|
||||||
|
const SEGMENTS_PER_RING = 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rotation that carries the galactic frame's axes onto the scene's equatorial ones, as a
|
||||||
|
* quaternion — so a grid built flat in XY comes out lying in the galactic plane, tilted the
|
||||||
|
* real 63 degrees against the celestial equator rather than parked on an arbitrary plane.
|
||||||
|
*/
|
||||||
|
export function galacticFrameQuaternion(): THREE.Quaternion {
|
||||||
|
const { x, y, z } = GALACTIC_BASIS_EQUATORIAL;
|
||||||
|
const basis = new THREE.Matrix4().makeBasis(new THREE.Vector3(x.x, x.y, x.z), new THREE.Vector3(y.x, y.y, y.z), new THREE.Vector3(z.x, z.y, z.z));
|
||||||
|
return new THREE.Quaternion().setFromRotationMatrix(basis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The galactic plane's unit normal, in the equatorial frame. */
|
||||||
|
export function galacticNormal(): THREE.Vector3 {
|
||||||
|
const { z } = GALACTIC_BASIS_EQUATORIAL;
|
||||||
|
return new THREE.Vector3(z.x, z.y, z.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolarGridOptions {
|
||||||
|
/** Ring radii to draw, in parsecs, innermost first. */
|
||||||
|
readonly ringRadiiPc: readonly number[];
|
||||||
|
/** Radial spokes drawn from the innermost to the outermost ring. */
|
||||||
|
readonly spokeCount: number;
|
||||||
|
/**
|
||||||
|
* Centre of the grid in the scene's equatorial frame, which also fixes the plane it lies in.
|
||||||
|
* Defaults to the Sun (the origin) — note that the Sun's own plane is
|
||||||
|
* {@link SUN_HEIGHT_ABOVE_MIDPLANE_PC} above the Galaxy's midplane, which matters at the local
|
||||||
|
* scale and is invisible at the galactic one.
|
||||||
|
*/
|
||||||
|
readonly centrePc?: THREE.Vector3;
|
||||||
|
readonly color?: THREE.ColorRepresentation;
|
||||||
|
/** Rings listed here are drawn at full strength — used to call out a meaningful radius. */
|
||||||
|
readonly emphasisRadiiPc?: readonly number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A polar grid lying in the galactic plane: concentric rings and radial spokes, fading out with
|
||||||
|
* radius.
|
||||||
|
*
|
||||||
|
* This is the one piece of chrome that makes a 3D star map readable. Without a reference plane
|
||||||
|
* a cloud of points has no depth at all — two stars a thousand parsecs apart look like
|
||||||
|
* neighbours. With a plane under them, and a tether from each to the plane, the eye reads their
|
||||||
|
* height directly. It is also the signature of the map this view is modelled on.
|
||||||
|
*/
|
||||||
|
export class PolarGridPlane {
|
||||||
|
readonly object: THREE.LineSegments;
|
||||||
|
|
||||||
|
private readonly geometry = new THREE.BufferGeometry();
|
||||||
|
private readonly material: THREE.LineBasicMaterial;
|
||||||
|
private readonly baseOpacity: number;
|
||||||
|
|
||||||
|
constructor(options: PolarGridOptions) {
|
||||||
|
const color = new THREE.Color(options.color ?? 0x4dd7ff);
|
||||||
|
const emphasis = new Set(options.emphasisRadiiPc ?? []);
|
||||||
|
const outerRadius = Math.max(...options.ringRadiiPc);
|
||||||
|
const innerRadius = Math.min(...options.ringRadiiPc);
|
||||||
|
|
||||||
|
const vertices: number[] = [];
|
||||||
|
const colors: number[] = [];
|
||||||
|
|
||||||
|
const push = (x: number, y: number, brightness: number): void => {
|
||||||
|
vertices.push(x, y, 0);
|
||||||
|
colors.push(color.r * brightness, color.g * brightness, color.b * brightness);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const radius of options.ringRadiiPc) {
|
||||||
|
// Rings dim toward the edge of the grid so it dissolves into the void instead of ending.
|
||||||
|
const brightness = emphasis.has(radius) ? 1 : 0.55 * (1 - (0.6 * radius) / outerRadius);
|
||||||
|
for (let segment = 0; segment < SEGMENTS_PER_RING; segment++) {
|
||||||
|
const a = (segment / SEGMENTS_PER_RING) * Math.PI * 2;
|
||||||
|
const b = ((segment + 1) / SEGMENTS_PER_RING) * Math.PI * 2;
|
||||||
|
push(Math.cos(a) * radius, Math.sin(a) * radius, brightness);
|
||||||
|
push(Math.cos(b) * radius, Math.sin(b) * radius, brightness);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let spoke = 0; spoke < options.spokeCount; spoke++) {
|
||||||
|
const angle = (spoke / options.spokeCount) * Math.PI * 2;
|
||||||
|
const cos = Math.cos(angle);
|
||||||
|
const sin = Math.sin(angle);
|
||||||
|
push(cos * innerRadius, sin * innerRadius, 0.4);
|
||||||
|
push(cos * outerRadius, sin * outerRadius, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
|
||||||
|
this.geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
|
||||||
|
|
||||||
|
// Deliberately restrained: the grid is the reference the map is read against, not the map.
|
||||||
|
this.baseOpacity = 0.55;
|
||||||
|
this.material = new THREE.LineBasicMaterial({
|
||||||
|
vertexColors: true,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
depthWrite: false,
|
||||||
|
blending: THREE.AdditiveBlending
|
||||||
|
});
|
||||||
|
|
||||||
|
this.object = new THREE.LineSegments(this.geometry, this.material);
|
||||||
|
// Built flat in its own XY plane, then rotated onto the galactic plane and slid to centre.
|
||||||
|
this.object.quaternion.copy(galacticFrameQuaternion());
|
||||||
|
this.object.position.copy(options.centrePc ?? new THREE.Vector3());
|
||||||
|
this.object.visible = false;
|
||||||
|
this.object.renderOrder = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crossfades the grid. Zero hides it outright rather than drawing a fully transparent pass. */
|
||||||
|
setStrength(strength: number): void {
|
||||||
|
const clamped = Math.max(0, Math.min(1, strength));
|
||||||
|
this.material.opacity = clamped * this.baseOpacity;
|
||||||
|
this.object.visible = clamped > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.object.removeFromParent();
|
||||||
|
this.geometry.dispose();
|
||||||
|
this.material.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The vertical lines dropped from objects onto the reference plane — the other half of what
|
||||||
|
* makes the grid work. A point floating over a grid still has ambiguous height; a point with a
|
||||||
|
* line down to a marked spot on the grid does not.
|
||||||
|
*
|
||||||
|
* Drawn as one `LineSegments` with a fixed-capacity buffer and a draw range, so following a
|
||||||
|
* changing set of stars costs a buffer write rather than a rebuild.
|
||||||
|
*/
|
||||||
|
export class TetherField {
|
||||||
|
readonly object: THREE.LineSegments;
|
||||||
|
|
||||||
|
private readonly geometry = new THREE.BufferGeometry();
|
||||||
|
private readonly material: THREE.LineBasicMaterial;
|
||||||
|
private readonly positions: Float32Array;
|
||||||
|
private readonly maxCount: number;
|
||||||
|
|
||||||
|
constructor(maxCount: number, color: THREE.ColorRepresentation = 0x4dd7ff) {
|
||||||
|
this.maxCount = maxCount;
|
||||||
|
this.positions = new Float32Array(maxCount * 6);
|
||||||
|
this.geometry.setAttribute('position', new THREE.BufferAttribute(this.positions, 3));
|
||||||
|
this.geometry.setDrawRange(0, 0);
|
||||||
|
|
||||||
|
this.material = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending });
|
||||||
|
this.object = new THREE.LineSegments(this.geometry, this.material);
|
||||||
|
// The buffer is rewritten in place as the visible set changes, so its bounds are stale by
|
||||||
|
// construction; culling on those bounds would blink the whole field in and out.
|
||||||
|
this.object.frustumCulled = false;
|
||||||
|
this.object.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops a tether from each point onto a plane parallel to the galactic plane.
|
||||||
|
*
|
||||||
|
* `planeHeightPc` is that plane's height above the Sun along the galactic normal, so it is `0`
|
||||||
|
* for a grid through the Sun and `-SUN_HEIGHT_ABOVE_MIDPLANE_PC` for one on the Galaxy's true
|
||||||
|
* midplane. Points past the field's capacity are dropped.
|
||||||
|
*/
|
||||||
|
setTargets(points: readonly THREE.Vector3[], planeHeightPc = 0): void {
|
||||||
|
const normal = galacticNormal();
|
||||||
|
const count = Math.min(points.length, this.maxCount);
|
||||||
|
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
const point = points[index];
|
||||||
|
const height = point.dot(normal) - planeHeightPc;
|
||||||
|
this.positions.set(
|
||||||
|
[point.x, point.y, point.z, point.x - normal.x * height, point.y - normal.y * height, point.z - normal.z * height],
|
||||||
|
index * 6
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.geometry.setDrawRange(0, count * 2);
|
||||||
|
this.geometry.getAttribute('position').needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crossfades the tethers, matching whichever grid they are dropping onto. */
|
||||||
|
setStrength(strength: number): void {
|
||||||
|
const clamped = Math.max(0, Math.min(1, strength));
|
||||||
|
this.material.opacity = clamped * 0.45;
|
||||||
|
this.object.visible = clamped > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.object.removeFromParent();
|
||||||
|
this.geometry.dispose();
|
||||||
|
this.material.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { DISC_RADIUS_PC, equatorialToGalactic, SUN_GALACTOCENTRIC_RADIUS_PC, SUN_HEIGHT_ABOVE_MIDPLANE_PC } from '../../shared/astro/galaxy';
|
||||||
|
import { createRandom, DEFAULT_PARTICLE_COUNTS, generateMilkyWayParticles } from './milky-way-model';
|
||||||
|
|
||||||
|
/** A small, fast budget — the shape of the model does not depend on how many particles trace it. */
|
||||||
|
const TEST_COUNTS = { arms: 3000, disc: 1500, bulge: 1200, halo: 200 };
|
||||||
|
const TEST_BUDGET = TEST_COUNTS.arms + TEST_COUNTS.disc + TEST_COUNTS.bulge + TEST_COUNTS.halo;
|
||||||
|
|
||||||
|
/** Galactocentric radius and height above the midplane of the i-th particle, in parsecs. */
|
||||||
|
function galactocentric(positions: Float32Array, index: number): { radiusPc: number; heightPc: number } {
|
||||||
|
const galactic = equatorialToGalactic({ x: positions[index * 3], y: positions[index * 3 + 1], z: positions[index * 3 + 2] });
|
||||||
|
return {
|
||||||
|
radiusPc: Math.hypot(SUN_GALACTOCENTRIC_RADIUS_PC - galactic.x, galactic.y),
|
||||||
|
heightPc: galactic.z + SUN_HEIGHT_ABOVE_MIDPLANE_PC
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function median(values: number[]): number {
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
return sorted[Math.floor(sorted.length / 2)];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createRandom', () => {
|
||||||
|
it('is deterministic for a given seed', () => {
|
||||||
|
const a = createRandom(7);
|
||||||
|
const b = createRandom(7);
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
expect(a()).toBe(b());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays inside the unit interval', () => {
|
||||||
|
const random = createRandom(99);
|
||||||
|
for (let i = 0; i < 5000; i++) {
|
||||||
|
const value = random();
|
||||||
|
expect(value).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(value).toBeLessThan(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces different streams for different seeds', () => {
|
||||||
|
expect(createRandom(1)()).not.toBe(createRandom(2)());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateMilkyWayParticles', () => {
|
||||||
|
const particles = generateMilkyWayParticles(1234, TEST_COUNTS);
|
||||||
|
|
||||||
|
it('is the same Galaxy on every run, so the map does not reshuffle on reload', () => {
|
||||||
|
const again = generateMilkyWayParticles(1234, TEST_COUNTS);
|
||||||
|
expect(again.count).toBe(particles.count);
|
||||||
|
expect(Array.from(again.positions.slice(0, 300))).toEqual(Array.from(particles.positions.slice(0, 300)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places most of the requested budget, rejecting only the samples that miss', () => {
|
||||||
|
expect(particles.count).toBeLessThanOrEqual(TEST_BUDGET);
|
||||||
|
expect(particles.count).toBeGreaterThan(TEST_BUDGET * 0.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits finite positions, sizes and alphas throughout', () => {
|
||||||
|
for (let index = 0; index < particles.count; index++) {
|
||||||
|
expect(Number.isFinite(particles.positions[index * 3])).toBe(true);
|
||||||
|
expect(Number.isFinite(particles.positions[index * 3 + 1])).toBe(true);
|
||||||
|
expect(Number.isFinite(particles.positions[index * 3 + 2])).toBe(true);
|
||||||
|
expect(particles.sizes[index]).toBeGreaterThan(0);
|
||||||
|
expect(particles.alphas[index]).toBeGreaterThan(0);
|
||||||
|
expect(particles.alphas[index]).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every colour channel inside the displayable range', () => {
|
||||||
|
for (let index = 0; index < particles.count * 3; index++) {
|
||||||
|
expect(particles.colors[index]).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(particles.colors[index]).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every particle inside the modelled galaxy, halo included', () => {
|
||||||
|
for (let index = 0; index < particles.count; index++) {
|
||||||
|
expect(galactocentric(particles.positions, index).radiusPc).toBeLessThan(DISC_RADIUS_PC * 1.3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a disc rather than a ball: half the particles sit within 300 pc of the midplane', () => {
|
||||||
|
const heights: number[] = [];
|
||||||
|
for (let index = 0; index < particles.count; index++) {
|
||||||
|
heights.push(Math.abs(galactocentric(particles.positions, index).heightPc));
|
||||||
|
}
|
||||||
|
expect(median(heights)).toBeLessThan(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the centre denser than the outskirts', () => {
|
||||||
|
let inner = 0;
|
||||||
|
let outer = 0;
|
||||||
|
for (let index = 0; index < particles.count; index++) {
|
||||||
|
const { radiusPc } = galactocentric(particles.positions, index);
|
||||||
|
if (radiusPc < 4000) {
|
||||||
|
inner++;
|
||||||
|
} else if (radiusPc > 12000) {
|
||||||
|
outer++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(inner).toBeGreaterThan(outer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the Sun in the disc, not off its edge', () => {
|
||||||
|
// The whole point of the placement: the local star field has to sit inside the model, about
|
||||||
|
// half way out, rather than floating beside it.
|
||||||
|
let neighbours = 0;
|
||||||
|
for (let index = 0; index < particles.count; index++) {
|
||||||
|
const x = particles.positions[index * 3];
|
||||||
|
const y = particles.positions[index * 3 + 1];
|
||||||
|
const z = particles.positions[index * 3 + 2];
|
||||||
|
if (Math.hypot(x, y, z) < 2000) {
|
||||||
|
neighbours++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(neighbours).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spans a full turn in azimuth, so the arms wrap rather than forming a fan', () => {
|
||||||
|
const quadrants = new Set<number>();
|
||||||
|
for (let index = 0; index < particles.count; index++) {
|
||||||
|
const galactic = equatorialToGalactic({
|
||||||
|
x: particles.positions[index * 3],
|
||||||
|
y: particles.positions[index * 3 + 1],
|
||||||
|
z: particles.positions[index * 3 + 2]
|
||||||
|
});
|
||||||
|
const angle = Math.atan2(galactic.y, SUN_GALACTOCENTRIC_RADIUS_PC - galactic.x);
|
||||||
|
quadrants.add(Math.floor(((angle + Math.PI) / (Math.PI / 2)) % 4));
|
||||||
|
}
|
||||||
|
expect(quadrants.size).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to a budget big enough to read as a galaxy', () => {
|
||||||
|
expect(DEFAULT_PARTICLE_COUNTS.arms).toBeGreaterThan(DEFAULT_PARTICLE_COUNTS.disc);
|
||||||
|
expect(DEFAULT_PARTICLE_COUNTS.halo).toBeLessThan(DEFAULT_PARTICLE_COUNTS.bulge);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import {
|
||||||
|
armRadiusPc,
|
||||||
|
BAR_HALF_LENGTH_PC,
|
||||||
|
BAR_HALF_THICKNESS_PC,
|
||||||
|
BAR_HALF_WIDTH_PC,
|
||||||
|
BAR_POSITION_ANGLE_DEG,
|
||||||
|
DISC_RADIUS_PC,
|
||||||
|
DISC_SCALE_HEIGHT_PC,
|
||||||
|
DISC_SCALE_LENGTH_PC,
|
||||||
|
galacticToEquatorial,
|
||||||
|
MILKY_WAY_ARMS,
|
||||||
|
ORION_SPUR,
|
||||||
|
SpiralArm,
|
||||||
|
SUN_GALACTOCENTRIC_RADIUS_PC,
|
||||||
|
SUN_HEIGHT_ABOVE_MIDPLANE_PC
|
||||||
|
} from '../../shared/astro/galaxy';
|
||||||
|
|
||||||
|
const DEG_TO_RAD = Math.PI / 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Particle budget per population. Between them these are ~46k instanced quads, which is the
|
||||||
|
* same order as the star field and draws in a single call.
|
||||||
|
*/
|
||||||
|
export interface GalaxyParticleCounts {
|
||||||
|
readonly arms: number;
|
||||||
|
readonly disc: number;
|
||||||
|
readonly bulge: number;
|
||||||
|
readonly halo: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PARTICLE_COUNTS: GalaxyParticleCounts = {
|
||||||
|
arms: 24000,
|
||||||
|
disc: 12000,
|
||||||
|
bulge: 9000,
|
||||||
|
halo: 1600
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Vertical scale height of the star-forming ridge in an arm — much thinner than the disc. */
|
||||||
|
const ARM_SCALE_HEIGHT_PC = 130;
|
||||||
|
/** Fraction of arm particles drawn as bright star-forming knots rather than ordinary field. */
|
||||||
|
const HII_REGION_FRACTION = 0.05;
|
||||||
|
|
||||||
|
/** Particle diameters in parsecs. These are cloud-sized on purpose: the model is haze, not stars. */
|
||||||
|
const ARM_SIZE_PC = { min: 70, max: 240 } as const;
|
||||||
|
const DISC_SIZE_PC = { min: 120, max: 420 } as const;
|
||||||
|
const BULGE_SIZE_PC = { min: 90, max: 300 } as const;
|
||||||
|
const HALO_SIZE_PC = { min: 110, max: 260 } as const;
|
||||||
|
const HII_SIZE_MULTIPLIER = 1.9;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The palette. Young blue-white stars trace the arms, star formation lights them pink, the
|
||||||
|
* bar and bulge are old and red, and the smooth disc between the arms is a dim yellow-white.
|
||||||
|
*/
|
||||||
|
const ARM_INNER_COLOR = [0.62, 0.74, 1.0] as const;
|
||||||
|
const ARM_OUTER_COLOR = [0.78, 0.86, 1.0] as const;
|
||||||
|
const HII_COLOR = [1.0, 0.48, 0.66] as const;
|
||||||
|
const BULGE_CORE_COLOR = [1.0, 0.87, 0.64] as const;
|
||||||
|
const BULGE_EDGE_COLOR = [1.0, 0.68, 0.38] as const;
|
||||||
|
const DISC_COLOR = [0.72, 0.74, 0.82] as const;
|
||||||
|
const HALO_COLOR = [0.55, 0.6, 0.78] as const;
|
||||||
|
|
||||||
|
export interface GalaxyParticles {
|
||||||
|
readonly count: number;
|
||||||
|
/** Equatorial-frame positions in parsecs from the Sun, packed xyz. */
|
||||||
|
readonly positions: Float32Array;
|
||||||
|
readonly colors: Float32Array;
|
||||||
|
/** World-space diameter, in parsecs. */
|
||||||
|
readonly sizes: Float32Array;
|
||||||
|
readonly alphas: Float32Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small, fast, seedable PRNG (mulberry32). `Math.random` would do visually, but the model would
|
||||||
|
* then be different on every reload and untestable — this way the Galaxy is the same Galaxy
|
||||||
|
* every time, and a test can assert on where its particles land.
|
||||||
|
*/
|
||||||
|
export function createRandom(seed: number): () => number {
|
||||||
|
let state = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
state = (state + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = state;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standard normal sample, by the polar form of Box-Muller. */
|
||||||
|
function gaussian(random: () => number): number {
|
||||||
|
let u = 0;
|
||||||
|
let v = 0;
|
||||||
|
let s = 0;
|
||||||
|
do {
|
||||||
|
u = random() * 2 - 1;
|
||||||
|
v = random() * 2 - 1;
|
||||||
|
s = u * u + v * v;
|
||||||
|
} while (s === 0 || s >= 1);
|
||||||
|
return u * Math.sqrt((-2 * Math.log(s)) / s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(a: number, b: number, t: number): number {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerpColor(a: readonly number[], b: readonly number[], t: number): [number, number, number] {
|
||||||
|
return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a galactocentric offset in the plane (heliocentric-parallel axes: +X toward the centre,
|
||||||
|
* +Y toward longitude 90) plus a height above the midplane into a heliocentric galactic
|
||||||
|
* position. The polar form of the same mapping lives in `galaxy.ts`; the bar is easier to write
|
||||||
|
* in Cartesian, so it gets this one.
|
||||||
|
*/
|
||||||
|
function fromCentreOffset(dx: number, dy: number, heightPc: number): { x: number; y: number; z: number } {
|
||||||
|
return { x: SUN_GALACTOCENTRIC_RADIUS_PC + dx, y: dy, z: heightPc - SUN_HEIGHT_ABOVE_MIDPLANE_PC };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Writer {
|
||||||
|
push(galactic: { x: number; y: number; z: number }, color: readonly number[], sizePc: number, alpha: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWriter(capacity: number): Writer & GalaxyParticles & { finish(): GalaxyParticles } {
|
||||||
|
const positions = new Float32Array(capacity * 3);
|
||||||
|
const colors = new Float32Array(capacity * 3);
|
||||||
|
const sizes = new Float32Array(capacity);
|
||||||
|
const alphas = new Float32Array(capacity);
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
positions,
|
||||||
|
colors,
|
||||||
|
sizes,
|
||||||
|
alphas,
|
||||||
|
get count() {
|
||||||
|
return count;
|
||||||
|
},
|
||||||
|
push(galactic, color, sizePc, alpha) {
|
||||||
|
// Everything is modelled in galactic coordinates and rotated once, here, into the frame
|
||||||
|
// the star field and orbits already share.
|
||||||
|
const equatorial = galacticToEquatorial(galactic);
|
||||||
|
positions[count * 3] = equatorial.x;
|
||||||
|
positions[count * 3 + 1] = equatorial.y;
|
||||||
|
positions[count * 3 + 2] = equatorial.z;
|
||||||
|
colors[count * 3] = color[0];
|
||||||
|
colors[count * 3 + 1] = color[1];
|
||||||
|
colors[count * 3 + 2] = color[2];
|
||||||
|
sizes[count] = sizePc;
|
||||||
|
alphas[count] = alpha;
|
||||||
|
count++;
|
||||||
|
},
|
||||||
|
finish() {
|
||||||
|
return { count, positions, colors, sizes, alphas };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks an arm, weighted, so the two grand-design arms dominate the minor ones. */
|
||||||
|
function pickArm(random: () => number, arms: readonly SpiralArm[]): SpiralArm {
|
||||||
|
const total = arms.reduce((sum, arm) => sum + arm.weight, 0);
|
||||||
|
let roll = random() * total;
|
||||||
|
for (const arm of arms) {
|
||||||
|
roll -= arm.weight;
|
||||||
|
if (roll <= 0) {
|
||||||
|
return arm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return arms[arms.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addArmParticles(writer: Writer, random: () => number, count: number): void {
|
||||||
|
const arms = [...MILKY_WAY_ARMS, ORION_SPUR];
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const arm = pickArm(random, arms);
|
||||||
|
// Biased toward the start of the sweep, which is the inner, denser end of every arm.
|
||||||
|
const t = Math.pow(random(), 1.4);
|
||||||
|
const beta = lerp(arm.fromAzimuthDeg, arm.toAzimuthDeg, t);
|
||||||
|
const spineRadius = armRadiusPc(arm, beta);
|
||||||
|
if (spineRadius > DISC_RADIUS_PC || spineRadius < BAR_HALF_LENGTH_PC * 0.5) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = gaussian(random) * arm.widthPc;
|
||||||
|
const radius = spineRadius + offset;
|
||||||
|
if (radius <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const height = gaussian(random) * ARM_SCALE_HEIGHT_PC;
|
||||||
|
const angle = beta * DEG_TO_RAD;
|
||||||
|
const galactic = fromCentreOffset(-radius * Math.cos(angle), radius * Math.sin(angle), height);
|
||||||
|
|
||||||
|
const radialT = Math.min(radius / DISC_RADIUS_PC, 1);
|
||||||
|
const isHii = random() < HII_REGION_FRACTION;
|
||||||
|
const color = isHii ? HII_COLOR : lerpColor(ARM_INNER_COLOR, ARM_OUTER_COLOR, radialT);
|
||||||
|
const size = lerp(ARM_SIZE_PC.min, ARM_SIZE_PC.max, random()) * (isHii ? HII_SIZE_MULTIPLIER : 1);
|
||||||
|
// Fades with radius (the arms thin out) and with distance off the spine (they have edges).
|
||||||
|
const ridgeFalloff = Math.exp(-(offset * offset) / (2 * arm.widthPc * arm.widthPc));
|
||||||
|
const alpha = (isHii ? 0.85 : 0.4) * ridgeFalloff * lerp(1, 0.35, radialT);
|
||||||
|
|
||||||
|
writer.push(galactic, color, size, alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDiscParticles(writer: Writer, random: () => number, count: number): void {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// Inverse-transform sample of an exponential disc, rejected past the visible edge.
|
||||||
|
const radius = -DISC_SCALE_LENGTH_PC * Math.log(1 - random());
|
||||||
|
// The inner cut is where the bulge takes over, not a hole: set it at the bar's short axis
|
||||||
|
// rather than its long one, or the model has a visible gap either side of the bar.
|
||||||
|
if (radius > DISC_RADIUS_PC || radius < BAR_HALF_WIDTH_PC) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const angle = random() * Math.PI * 2;
|
||||||
|
const height = gaussian(random) * DISC_SCALE_HEIGHT_PC;
|
||||||
|
const galactic = fromCentreOffset(-radius * Math.cos(angle), radius * Math.sin(angle), height);
|
||||||
|
|
||||||
|
const radialT = Math.min(radius / DISC_RADIUS_PC, 1);
|
||||||
|
writer.push(galactic, DISC_COLOR, lerp(DISC_SIZE_PC.min, DISC_SIZE_PC.max, random()), lerp(0.16, 0.03, radialT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share of the bulge budget spent on the bar rather than on the rounder spheroid it sits inside.
|
||||||
|
* Both are needed: the bar alone leaves a void either side of its short axis, between it and the
|
||||||
|
* radius the arms and disc start at.
|
||||||
|
*/
|
||||||
|
const BAR_SHARE_OF_BULGE = 0.6;
|
||||||
|
/** Gaussian width of the inner spheroid, and how much it is flattened toward the disc. */
|
||||||
|
const SPHEROID_SIGMA_PC = 1150;
|
||||||
|
const SPHEROID_FLATTENING = 0.62;
|
||||||
|
|
||||||
|
function addBulgeParticles(writer: Writer, random: () => number, count: number): void {
|
||||||
|
const phi = BAR_POSITION_ANGLE_DEG * DEG_TO_RAD;
|
||||||
|
const alongX = -Math.cos(phi);
|
||||||
|
const alongY = Math.sin(phi);
|
||||||
|
const acrossX = Math.sin(phi);
|
||||||
|
const acrossY = Math.cos(phi);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
let dx: number;
|
||||||
|
let dy: number;
|
||||||
|
let height: number;
|
||||||
|
let distance: number;
|
||||||
|
|
||||||
|
if (random() < BAR_SHARE_OF_BULGE) {
|
||||||
|
// A triaxial Gaussian: long down the bar, narrow across it, flattened vertically.
|
||||||
|
const along = gaussian(random) * (BAR_HALF_LENGTH_PC / 2);
|
||||||
|
const across = gaussian(random) * (BAR_HALF_WIDTH_PC / 2);
|
||||||
|
height = gaussian(random) * (BAR_HALF_THICKNESS_PC / 2);
|
||||||
|
dx = along * alongX + across * acrossX;
|
||||||
|
dy = along * alongY + across * acrossY;
|
||||||
|
distance = Math.hypot(along, across, height);
|
||||||
|
} else {
|
||||||
|
dx = gaussian(random) * SPHEROID_SIGMA_PC;
|
||||||
|
dy = gaussian(random) * SPHEROID_SIGMA_PC;
|
||||||
|
height = gaussian(random) * SPHEROID_SIGMA_PC * SPHEROID_FLATTENING;
|
||||||
|
distance = Math.hypot(dx, dy, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreT = Math.min(distance / BAR_HALF_LENGTH_PC, 1);
|
||||||
|
writer.push(
|
||||||
|
fromCentreOffset(dx, dy, height),
|
||||||
|
lerpColor(BULGE_CORE_COLOR, BULGE_EDGE_COLOR, coreT),
|
||||||
|
lerp(BULGE_SIZE_PC.min, BULGE_SIZE_PC.max, random()),
|
||||||
|
lerp(0.3, 0.06, coreT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHaloParticles(writer: Writer, random: () => number, count: number): void {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// A thin spherical scatter (globular clusters and halo field) so the disc is not a bare
|
||||||
|
// plate floating in the void.
|
||||||
|
const radius = DISC_RADIUS_PC * (0.35 + 0.85 * Math.pow(random(), 0.7));
|
||||||
|
const cosTheta = random() * 2 - 1;
|
||||||
|
const sinTheta = Math.sqrt(1 - cosTheta * cosTheta);
|
||||||
|
const angle = random() * Math.PI * 2;
|
||||||
|
const galactic = fromCentreOffset(radius * sinTheta * Math.cos(angle), radius * sinTheta * Math.sin(angle), radius * cosTheta);
|
||||||
|
|
||||||
|
writer.push(galactic, HALO_COLOR, lerp(HALO_SIZE_PC.min, HALO_SIZE_PC.max, random()), 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scatters the Milky Way's particle cloud around the structural model in `galaxy.ts` and returns
|
||||||
|
* it packed for instanced rendering, already rotated into the scene's equatorial frame.
|
||||||
|
*
|
||||||
|
* Some samples are rejected (an arm particle that lands inside the bar, a disc particle past the
|
||||||
|
* visible edge), so the returned `count` is a little below the requested budget — the arrays are
|
||||||
|
* allocated at capacity and the count says how much of them is live.
|
||||||
|
*/
|
||||||
|
export function generateMilkyWayParticles(seed = 20260804, counts: GalaxyParticleCounts = DEFAULT_PARTICLE_COUNTS): GalaxyParticles {
|
||||||
|
const random = createRandom(seed);
|
||||||
|
const writer = createWriter(counts.arms + counts.disc + counts.bulge + counts.halo);
|
||||||
|
|
||||||
|
addBulgeParticles(writer, random, counts.bulge);
|
||||||
|
addArmParticles(writer, random, counts.arms);
|
||||||
|
addDiscParticles(writer, random, counts.disc);
|
||||||
|
addHaloParticles(writer, random, counts.halo);
|
||||||
|
|
||||||
|
return writer.finish();
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import * as THREE from 'three/webgpu';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { GALACTIC_LANDMARKS } from '../../shared/astro/galaxy';
|
||||||
|
import { GALAXY_FADE_FAR_PC, GALAXY_FADE_NEAR_PC, MilkyWayRenderer } from './milky-way-renderer';
|
||||||
|
|
||||||
|
const SMALL_COUNTS = { arms: 400, disc: 200, bulge: 200, halo: 50 };
|
||||||
|
|
||||||
|
describe('MilkyWayRenderer', () => {
|
||||||
|
it('draws one instance per particle the model placed', () => {
|
||||||
|
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||||
|
expect(renderer.particleCount).toBeGreaterThan(0);
|
||||||
|
expect((renderer.object.geometry as THREE.InstancedBufferGeometry).instanceCount).toBe(renderer.particleCount);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays hidden until the camera has pulled back, so the local view pays nothing for it', () => {
|
||||||
|
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||||
|
expect(renderer.object.visible).toBe(false);
|
||||||
|
|
||||||
|
expect(renderer.setViewerDistancePc(50)).toBe(0);
|
||||||
|
expect(renderer.object.visible).toBe(false);
|
||||||
|
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fades in across the crossfade band and holds at full strength beyond it', () => {
|
||||||
|
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||||
|
|
||||||
|
expect(renderer.setViewerDistancePc(GALAXY_FADE_NEAR_PC)).toBe(0);
|
||||||
|
expect(renderer.setViewerDistancePc((GALAXY_FADE_NEAR_PC + GALAXY_FADE_FAR_PC) / 2)).toBeCloseTo(0.5, 6);
|
||||||
|
expect(renderer.setViewerDistancePc(GALAXY_FADE_FAR_PC)).toBe(1);
|
||||||
|
expect(renderer.setViewerDistancePc(80000)).toBe(1);
|
||||||
|
expect(renderer.object.visible).toBe(true);
|
||||||
|
expect(renderer.strength).toBe(1);
|
||||||
|
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never culls itself, since its instances are nowhere near the geometry it was built from', () => {
|
||||||
|
// The billboard quad sits at the origin and the particles are placed in the vertex shader,
|
||||||
|
// so the mesh's own bounds say nothing about where it is drawn.
|
||||||
|
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||||
|
expect(renderer.object.frustumCulled).toBe(false);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers a label anchor for every structural landmark, namespaced away from the star ids', () => {
|
||||||
|
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||||
|
const labels = renderer.labelPoints();
|
||||||
|
|
||||||
|
expect(labels).toHaveLength(GALACTIC_LANDMARKS.length);
|
||||||
|
expect(labels.map((label) => label.name)).toContain('Sagittarius A*');
|
||||||
|
for (const label of labels) {
|
||||||
|
expect(String(label.id).startsWith('galactic:')).toBe(true);
|
||||||
|
expect(Number.isFinite(label.x + label.y + label.z)).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detaches itself on dispose, so a torn-down scene does not keep it alive', () => {
|
||||||
|
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||||
|
new THREE.Group().add(renderer.object);
|
||||||
|
|
||||||
|
renderer.dispose();
|
||||||
|
expect(renderer.object.parent).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import * as THREE from 'three/webgpu';
|
||||||
|
import { instancedBufferAttribute, smoothstep, uniform, uv, vec2 } from 'three/tsl';
|
||||||
|
|
||||||
|
import { GALACTIC_LANDMARKS, landmarkPositionPc } from '../../shared/astro/galaxy';
|
||||||
|
import { LabeledPoint } from './star-label-overlay';
|
||||||
|
import { generateMilkyWayParticles, GalaxyParticleCounts } from './milky-way-model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Camera distances (parsecs from the Sun) between which the Galaxy model fades in. Below the
|
||||||
|
* near figure the view is the real, measured star field and the model is entirely hidden; above
|
||||||
|
* the far figure the model is at full strength and the 50 pc catalogue bubble is a single point.
|
||||||
|
*/
|
||||||
|
export const GALAXY_FADE_NEAR_PC = 400;
|
||||||
|
export const GALAXY_FADE_FAR_PC = 2500;
|
||||||
|
|
||||||
|
/** A unit quad centred on the origin — the billboard every particle is drawn on. */
|
||||||
|
function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometry {
|
||||||
|
const geometry = new THREE.InstancedBufferGeometry();
|
||||||
|
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]), 3));
|
||||||
|
geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), 2));
|
||||||
|
geometry.setIndex([0, 1, 2, 0, 2, 3]);
|
||||||
|
geometry.instanceCount = instanceCount;
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws the Milky Way itself: the bar and bulge, five spiral arms, the smooth disc between them
|
||||||
|
* and a thin halo, as one instanced cloud of soft camera-facing billboards.
|
||||||
|
*
|
||||||
|
* The particles are **illustrative**. Their skeleton is not — arm radii, pitch angles, the
|
||||||
|
* Sun's galactocentric distance and the tilt of the disc against the sky are all measured
|
||||||
|
* quantities, and the model is built from them in `galaxy.ts`. What no catalogue can supply is
|
||||||
|
* the position of each star in the disc, because dust hides most of it from us, so the cloud
|
||||||
|
* around that skeleton is scattered rather than observed. The UI says so on the galactic level.
|
||||||
|
*
|
||||||
|
* Sizes are world-space here, unlike the star field's angular ones: these particles stand for
|
||||||
|
* clouds hundreds of parsecs across, so they should grow as the camera closes on them.
|
||||||
|
*/
|
||||||
|
export class MilkyWayRenderer {
|
||||||
|
readonly object: THREE.Mesh;
|
||||||
|
/** How many instances the model actually placed, after rejected samples. */
|
||||||
|
readonly particleCount: number;
|
||||||
|
|
||||||
|
private readonly geometry: THREE.InstancedBufferGeometry;
|
||||||
|
private readonly material: THREE.SpriteNodeMaterial;
|
||||||
|
private readonly fade = uniform(0);
|
||||||
|
private fadeValue = 0;
|
||||||
|
|
||||||
|
constructor(seed?: number, counts?: GalaxyParticleCounts) {
|
||||||
|
const particles = generateMilkyWayParticles(seed, counts);
|
||||||
|
this.particleCount = particles.count;
|
||||||
|
this.geometry = createQuadGeometry(particles.count);
|
||||||
|
|
||||||
|
const positionAttribute = new THREE.InstancedBufferAttribute(particles.positions, 3);
|
||||||
|
const colorAttribute = new THREE.InstancedBufferAttribute(particles.colors, 3);
|
||||||
|
const sizeAttribute = new THREE.InstancedBufferAttribute(particles.sizes, 1);
|
||||||
|
const alphaAttribute = new THREE.InstancedBufferAttribute(particles.alphas, 1);
|
||||||
|
|
||||||
|
this.material = new THREE.SpriteNodeMaterial({
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
depthTest: false,
|
||||||
|
blending: THREE.AdditiveBlending
|
||||||
|
});
|
||||||
|
this.material.positionNode = instancedBufferAttribute(positionAttribute, 'vec3');
|
||||||
|
this.material.scaleNode = instancedBufferAttribute(sizeAttribute, 'float');
|
||||||
|
this.material.colorNode = instancedBufferAttribute(colorAttribute, 'vec3');
|
||||||
|
// A gentler falloff than the star field's: these are clouds, and the tight curve that makes
|
||||||
|
// a star read as a bright point makes a cloud read as a solid ball.
|
||||||
|
const radius = uv().sub(vec2(0.5)).length();
|
||||||
|
const falloff = smoothstep(0.0, 0.5, radius).oneMinus().pow(1.6);
|
||||||
|
this.material.opacityNode = falloff.mul(instancedBufferAttribute(alphaAttribute, 'float')).mul(this.fade);
|
||||||
|
|
||||||
|
this.object = new THREE.Mesh(this.geometry, this.material);
|
||||||
|
// The quad's own bounds sit at the origin and say nothing about where the instances are.
|
||||||
|
this.object.frustumCulled = false;
|
||||||
|
this.object.visible = false;
|
||||||
|
// Behind everything else: the model is a backdrop for the real data, never in front of it.
|
||||||
|
this.object.renderOrder = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crossfades the model against how far the camera has pulled back, and returns the resulting
|
||||||
|
* strength (0-1). The mesh is skipped outright at zero so the local view pays nothing for it.
|
||||||
|
*/
|
||||||
|
setViewerDistancePc(distancePc: number): number {
|
||||||
|
const t = (distancePc - GALAXY_FADE_NEAR_PC) / (GALAXY_FADE_FAR_PC - GALAXY_FADE_NEAR_PC);
|
||||||
|
this.fadeValue = Math.max(0, Math.min(1, t));
|
||||||
|
this.fade.value = this.fadeValue;
|
||||||
|
this.object.visible = this.fadeValue > 0;
|
||||||
|
return this.fadeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
get strength(): number {
|
||||||
|
return this.fadeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Named structural landmarks — the centre, the Sun, and one label per arm. */
|
||||||
|
labelPoints(): readonly LabeledPoint[] {
|
||||||
|
return GALACTIC_LANDMARKS.map((landmark) => {
|
||||||
|
const position = landmarkPositionPc(landmark);
|
||||||
|
return { id: `galactic:${landmark.id}`, name: landmark.name, x: position.x, y: position.y, z: position.z };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.object.removeFromParent();
|
||||||
|
this.geometry.dispose();
|
||||||
|
this.material.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { ViewLevel } from '../../shared/state/navigation.store';
|
||||||
|
import { StarmapHudComponent } from './starmap-hud.component';
|
||||||
|
|
||||||
|
describe('StarmapHudComponent', () => {
|
||||||
|
let fixture: ComponentFixture<StarmapHudComponent>;
|
||||||
|
|
||||||
|
function render(level: ViewLevel): HTMLElement {
|
||||||
|
fixture.componentRef.setInput('level', level);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture.nativeElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonLabels(host: HTMLElement): string[] {
|
||||||
|
return [...host.querySelectorAll('nav button')].map((button) => button.textContent?.trim() ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({ imports: [StarmapHudComponent] }).compileComponents();
|
||||||
|
fixture = TestBed.createComponent(StarmapHudComponent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the way back down from the outermost scale', () => {
|
||||||
|
// The two outer scales share one space, so the ladder goes both ways between them.
|
||||||
|
expect(buttonLabels(render('galactic'))).toEqual(['Solar Neighbourhood']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the Milky Way from the solar neighbourhood', () => {
|
||||||
|
expect(buttonLabels(render('galaxy'))).toEqual(['Milky Way']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers both wider scales from inside a system', () => {
|
||||||
|
expect(buttonLabels(render('system'))).toEqual(['Milky Way', 'Solar Neighbourhood']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never offers the system scale, which needs a star picked first', () => {
|
||||||
|
for (const level of ['galactic', 'galaxy', 'system'] as const) {
|
||||||
|
expect(buttonLabels(render(level))).not.toContain('System');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the current scale rather than making it a button that goes nowhere', () => {
|
||||||
|
// Load-bearing beyond tidiness: this marker is how the end-to-end tests tell which scale the
|
||||||
|
// view has actually settled at.
|
||||||
|
const host = render('galaxy');
|
||||||
|
const current = host.querySelector('[data-testid="hud-current-level"]');
|
||||||
|
expect(current?.textContent?.trim()).toBe('Solar Neighbourhood');
|
||||||
|
expect(current?.getAttribute('aria-current')).toBe('step');
|
||||||
|
expect(buttonLabels(host)).not.toContain('Solar Neighbourhood');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks exactly one scale as current', () => {
|
||||||
|
for (const level of ['galactic', 'galaxy', 'system'] as const) {
|
||||||
|
expect(render(level).querySelectorAll('[data-testid="hud-current-level"]')).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits the scale that was asked for', () => {
|
||||||
|
const host = render('system');
|
||||||
|
const emitted: ViewLevel[] = [];
|
||||||
|
fixture.componentInstance.levelSelected.subscribe((level) => emitted.push(level));
|
||||||
|
|
||||||
|
host.querySelectorAll('nav button').forEach((button) => (button as HTMLButtonElement).click());
|
||||||
|
|
||||||
|
expect(emitted).toEqual(['galactic', 'galaxy']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the readout panel from its inputs', () => {
|
||||||
|
fixture.componentRef.setInput('level', 'galactic');
|
||||||
|
fixture.componentRef.setInput('eyebrow', 'Galactic Scale');
|
||||||
|
fixture.componentRef.setInput('title', 'Milky Way');
|
||||||
|
fixture.componentRef.setInput('subtitle', 'Barred spiral');
|
||||||
|
fixture.componentRef.setInput('readouts', [{ label: 'Arms', value: '5' }]);
|
||||||
|
fixture.componentRef.setInput('note', 'Illustrative model.');
|
||||||
|
fixture.componentRef.setInput('range', '21.5 kpc');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||||
|
for (const expected of ['Galactic Scale', 'Milky Way', 'Barred spiral', 'Arms', '5', 'Illustrative model.', '21.5 kpc']) {
|
||||||
|
expect(text).toContain(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves out the optional lines it was given nothing for', () => {
|
||||||
|
const host = render('galaxy');
|
||||||
|
expect(host.querySelector('dl')).toBeNull();
|
||||||
|
expect(host.textContent).not.toContain('undefined');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||||
|
|
||||||
|
import { ViewLevel } from '../../shared/state/navigation.store';
|
||||||
|
|
||||||
|
export interface HudReadout {
|
||||||
|
readonly label: string;
|
||||||
|
readonly value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LadderStep {
|
||||||
|
readonly level: ViewLevel;
|
||||||
|
readonly label: string;
|
||||||
|
/** Above the active step, on it, or below it — which is what the step is styled from. */
|
||||||
|
readonly state: 'above' | 'current' | 'below';
|
||||||
|
/** Whether this step is somewhere the view can be sent right now. */
|
||||||
|
readonly reachable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outermost first — the order the ladder is drawn in, and the order levels nest. */
|
||||||
|
const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
||||||
|
{ level: 'galactic', label: 'Milky Way' },
|
||||||
|
{ level: 'galaxy', label: 'Solar Neighbourhood' },
|
||||||
|
{ level: 'system', label: 'System' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The map's heads-up display: the scale ladder down the left, the readout panel across the
|
||||||
|
* bottom, a centre reticle on whatever the camera is holding, and the frame brackets around
|
||||||
|
* the whole viewport.
|
||||||
|
*
|
||||||
|
* Purely presentational — every value arrives as an input and the only thing it emits is a
|
||||||
|
* request to move to another scale. The scene owns the camera and decides what that means.
|
||||||
|
*
|
||||||
|
* Reachable levels render as buttons and the current one renders as a static marker rather than
|
||||||
|
* a button that does nothing. The galactic and neighbourhood scales are always reachable, in
|
||||||
|
* both directions: they are one continuous space and the Sun is always in it. The system scale
|
||||||
|
* is not, since there is no system to go to until a star has been picked.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-starmap-hud',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: { class: 'pointer-events-none absolute inset-0 block select-none' },
|
||||||
|
template: `
|
||||||
|
<div class="hud-frame absolute inset-0"></div>
|
||||||
|
<div class="hud-vignette absolute inset-0"></div>
|
||||||
|
|
||||||
|
@if (showReticle()) {
|
||||||
|
<div class="absolute top-1/2 left-1/2 h-16 w-16 -translate-x-1/2 -translate-y-1/2">
|
||||||
|
<span class="absolute top-0 left-0 h-3 w-3 border-t border-l border-accent/70"></span>
|
||||||
|
<span class="absolute top-0 right-0 h-3 w-3 border-t border-r border-accent/70"></span>
|
||||||
|
<span class="absolute bottom-0 left-0 h-3 w-3 border-b border-l border-accent/70"></span>
|
||||||
|
<span class="absolute right-0 bottom-0 h-3 w-3 border-r border-b border-accent/70"></span>
|
||||||
|
<span class="absolute top-1/2 left-1/2 h-px w-2 -translate-x-1/2 -translate-y-1/2 bg-accent/60"></span>
|
||||||
|
<span class="absolute top-1/2 left-1/2 h-2 w-px -translate-x-1/2 -translate-y-1/2 bg-accent/60"></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<nav aria-label="Map scale" class="pointer-events-auto absolute top-1/2 left-6 flex -translate-y-1/2 flex-col gap-5">
|
||||||
|
@for (step of ladder(); track step.level) {
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="block h-2 w-2 rotate-45 border"
|
||||||
|
[class]="step.state === 'current' ? 'border-accent bg-accent shadow-[0_0_10px_var(--color-accent)]' : step.state === 'above' ? 'border-accent/60' : 'border-border/60'"
|
||||||
|
></span>
|
||||||
|
@if (step.reachable) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="levelSelected.emit(step.level)"
|
||||||
|
class="font-display text-[10px] tracking-[0.22em] text-muted uppercase transition-colors hover:text-accent focus:text-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
{{ step.label }}
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<span
|
||||||
|
[attr.aria-current]="step.state === 'current' ? 'step' : null"
|
||||||
|
[attr.data-testid]="step.state === 'current' ? 'hud-current-level' : null"
|
||||||
|
class="font-display text-[10px] tracking-[0.22em] uppercase"
|
||||||
|
[class]="step.state === 'current' ? 'text-accent' : 'text-border'"
|
||||||
|
>{{ step.label }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="absolute right-6 bottom-6 left-6 flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div class="hud-panel max-w-lg px-4 py-3">
|
||||||
|
<p class="font-display text-[10px] tracking-[0.28em] text-muted uppercase">{{ eyebrow() }}</p>
|
||||||
|
<p class="mt-1 font-display text-xl tracking-[0.06em] text-text">{{ title() }}</p>
|
||||||
|
@if (subtitle()) {
|
||||||
|
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
|
||||||
|
}
|
||||||
|
@if (readouts().length) {
|
||||||
|
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
||||||
|
@for (readout of readouts(); track readout.label) {
|
||||||
|
<div>
|
||||||
|
<dt class="text-[10px] tracking-[0.18em] text-muted uppercase">{{ readout.label }}</dt>
|
||||||
|
<dd class="text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
@if (note()) {
|
||||||
|
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed tracking-[0.08em] text-muted uppercase">{{ note() }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hud-panel px-4 py-3 text-right">
|
||||||
|
<p class="font-display text-[10px] tracking-[0.28em] text-muted uppercase">Range</p>
|
||||||
|
<p class="mt-1 font-display text-lg text-accent tabular-nums">{{ range() }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class StarmapHudComponent {
|
||||||
|
readonly level = input.required<ViewLevel>();
|
||||||
|
/** Headline for the readout panel — the selected star, or the name of the current scale. */
|
||||||
|
readonly title = input('');
|
||||||
|
readonly subtitle = input('');
|
||||||
|
readonly eyebrow = input('');
|
||||||
|
readonly readouts = input<readonly HudReadout[]>([]);
|
||||||
|
/** Standing caveat for the current view, e.g. that galactic structure is a model. */
|
||||||
|
readonly note = input('');
|
||||||
|
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
|
||||||
|
readonly range = input('');
|
||||||
|
readonly showReticle = input(true);
|
||||||
|
|
||||||
|
readonly levelSelected = output<ViewLevel>();
|
||||||
|
|
||||||
|
readonly ladder = computed<readonly LadderStep[]>(() => {
|
||||||
|
const activeIndex = LADDER.findIndex((step) => step.level === this.level());
|
||||||
|
return LADDER.map((step, index) => ({
|
||||||
|
...step,
|
||||||
|
state: index < activeIndex ? 'above' : index === activeIndex ? 'current' : 'below',
|
||||||
|
// Every scale is somewhere the camera can be sent except the current one and the system
|
||||||
|
// level, which needs a star picked first — there is no "the system" without a selection.
|
||||||
|
reachable: index !== activeIndex && step.level !== 'system'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { CartesianCoordinates } from './coordinates';
|
||||||
|
import {
|
||||||
|
armRadiusPc,
|
||||||
|
BAR_HALF_LENGTH_PC,
|
||||||
|
DISC_RADIUS_PC,
|
||||||
|
equatorialToGalactic,
|
||||||
|
GALACTIC_BASIS_EQUATORIAL,
|
||||||
|
GALACTIC_LANDMARKS,
|
||||||
|
galacticCentrePositionPc,
|
||||||
|
galacticToEquatorial,
|
||||||
|
galactocentricToHeliocentricGalactic,
|
||||||
|
landmarkPositionPc,
|
||||||
|
MILKY_WAY_ARMS,
|
||||||
|
ORION_SPUR,
|
||||||
|
SUN_GALACTOCENTRIC_RADIUS_PC,
|
||||||
|
SUN_HEIGHT_ABOVE_MIDPLANE_PC
|
||||||
|
} from './galaxy';
|
||||||
|
|
||||||
|
const RAD_TO_DEG = 180 / Math.PI;
|
||||||
|
|
||||||
|
function dot(a: CartesianCoordinates, b: CartesianCoordinates): number {
|
||||||
|
return a.x * b.x + a.y * b.y + a.z * b.z;
|
||||||
|
}
|
||||||
|
|
||||||
|
function length(v: CartesianCoordinates): number {
|
||||||
|
return Math.hypot(v.x, v.y, v.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Galactic longitude of a heliocentric galactic vector, in degrees, 0-360. */
|
||||||
|
function galacticLongitudeDeg(v: CartesianCoordinates): number {
|
||||||
|
return (Math.atan2(v.y, v.x) * RAD_TO_DEG + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GALACTIC_BASIS_EQUATORIAL', () => {
|
||||||
|
it('is an orthonormal basis', () => {
|
||||||
|
const { x, y, z } = GALACTIC_BASIS_EQUATORIAL;
|
||||||
|
for (const axis of [x, y, z]) {
|
||||||
|
expect(length(axis)).toBeCloseTo(1, 12);
|
||||||
|
}
|
||||||
|
expect(dot(x, y)).toBeCloseTo(0, 12);
|
||||||
|
expect(dot(y, z)).toBeCloseTo(0, 12);
|
||||||
|
expect(dot(z, x)).toBeCloseTo(0, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is right-handed, so the model is rotated rather than mirrored', () => {
|
||||||
|
const { x, y, z } = GALACTIC_BASIS_EQUATORIAL;
|
||||||
|
const cross = { x: x.y * y.z - x.z * y.y, y: x.z * y.x - x.x * y.z, z: x.x * y.y - x.y * y.x };
|
||||||
|
expect(cross.x).toBeCloseTo(z.x, 12);
|
||||||
|
expect(cross.y).toBeCloseTo(z.y, 12);
|
||||||
|
expect(cross.z).toBeCloseTo(z.z, 12);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('galacticToEquatorial', () => {
|
||||||
|
it('is inverted exactly by equatorialToGalactic', () => {
|
||||||
|
for (const point of [
|
||||||
|
{ x: 1, y: 0, z: 0 },
|
||||||
|
{ x: 0, y: 0, z: 1 },
|
||||||
|
{ x: -8178, y: 4200, z: -75 }
|
||||||
|
]) {
|
||||||
|
const round = equatorialToGalactic(galacticToEquatorial(point));
|
||||||
|
expect(round.x).toBeCloseTo(point.x, 8);
|
||||||
|
expect(round.y).toBeCloseTo(point.y, 8);
|
||||||
|
expect(round.z).toBeCloseTo(point.z, 8);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves length, being a rotation', () => {
|
||||||
|
const rotated = galacticToEquatorial({ x: 300, y: -450, z: 120 });
|
||||||
|
expect(length(rotated)).toBeCloseTo(length({ x: 300, y: -450, z: 120 }), 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the galactic pole to the catalogued equatorial direction of the pole', () => {
|
||||||
|
// Dec of the north galactic pole is +27.12825 degrees, so its equatorial z is sin of that.
|
||||||
|
const pole = galacticToEquatorial({ x: 0, y: 0, z: 1 });
|
||||||
|
expect(Math.asin(pole.z) * RAD_TO_DEG).toBeCloseTo(27.12825, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the galactic plane at about 60 degrees to the celestial equator', () => {
|
||||||
|
// The complement of the pole's declination: the two planes are as far apart as their poles.
|
||||||
|
const pole = galacticToEquatorial({ x: 0, y: 0, z: 1 });
|
||||||
|
expect(90 - Math.asin(pole.z) * RAD_TO_DEG).toBeCloseTo(62.87, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('galactocentricToHeliocentricGalactic', () => {
|
||||||
|
it('places the Sun at the origin, at its own radius and zero azimuth', () => {
|
||||||
|
const sun = galactocentricToHeliocentricGalactic(SUN_GALACTOCENTRIC_RADIUS_PC, 0, SUN_HEIGHT_ABOVE_MIDPLANE_PC);
|
||||||
|
expect(sun.x).toBeCloseTo(0, 9);
|
||||||
|
expect(sun.y).toBeCloseTo(0, 9);
|
||||||
|
expect(sun.z).toBeCloseTo(0, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the midplane below the Sun, not through it', () => {
|
||||||
|
const belowSun = galactocentricToHeliocentricGalactic(SUN_GALACTOCENTRIC_RADIUS_PC, 0, 0);
|
||||||
|
expect(belowSun.z).toBeCloseTo(-SUN_HEIGHT_ABOVE_MIDPLANE_PC, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places the galactic centre toward longitude zero at the Sun-centre distance', () => {
|
||||||
|
const centre = galactocentricToHeliocentricGalactic(0, 0, 0);
|
||||||
|
expect(galacticLongitudeDeg(centre)).toBeCloseTo(0, 6);
|
||||||
|
expect(Math.hypot(centre.x, centre.y)).toBeCloseTo(SUN_GALACTOCENTRIC_RADIUS_PC, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends increasing azimuth toward longitude 90, the direction of rotation', () => {
|
||||||
|
const ahead = galactocentricToHeliocentricGalactic(SUN_GALACTOCENTRIC_RADIUS_PC, 30, 0);
|
||||||
|
expect(ahead.y).toBeGreaterThan(0);
|
||||||
|
expect(galacticLongitudeDeg(ahead)).toBeGreaterThan(0);
|
||||||
|
expect(galacticLongitudeDeg(ahead)).toBeLessThan(180);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('galacticCentrePositionPc', () => {
|
||||||
|
it('is the Sun-centre distance away, in the equatorial frame', () => {
|
||||||
|
expect(length(galacticCentrePositionPc())).toBeCloseTo(Math.hypot(SUN_GALACTOCENTRIC_RADIUS_PC, SUN_HEIGHT_ABOVE_MIDPLANE_PC), 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lands within a tenth of a degree of the catalogued direction of Sagittarius A*', () => {
|
||||||
|
// The two are not identical by construction: galactic latitude zero is defined by the disc
|
||||||
|
// the Sun orbits in, and Sgr A* sits a few hundredths of a degree off it.
|
||||||
|
const centre = galacticCentrePositionPc();
|
||||||
|
const catalogued = galacticToEquatorial({ x: 1, y: 0, z: 0 });
|
||||||
|
const cosAngle = dot(centre, catalogued) / length(centre);
|
||||||
|
expect(Math.acos(cosAngle) * RAD_TO_DEG).toBeLessThan(0.2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('armRadiusPc', () => {
|
||||||
|
it('returns the reference radius at the reference azimuth', () => {
|
||||||
|
for (const arm of MILKY_WAY_ARMS) {
|
||||||
|
expect(armRadiusPc(arm, arm.referenceAzimuthDeg)).toBeCloseTo(arm.referenceRadiusPc, 6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('winds inward as azimuth increases, so the arms trail', () => {
|
||||||
|
for (const arm of MILKY_WAY_ARMS) {
|
||||||
|
expect(armRadiusPc(arm, arm.referenceAzimuthDeg + 40)).toBeLessThan(arm.referenceRadiusPc);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every arm inside the modelled disc over its traced range', () => {
|
||||||
|
for (const arm of [...MILKY_WAY_ARMS, ORION_SPUR]) {
|
||||||
|
expect(armRadiusPc(arm, arm.fromAzimuthDeg)).toBeLessThanOrEqual(DISC_RADIUS_PC);
|
||||||
|
expect(armRadiusPc(arm, arm.toAzimuthDeg)).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every arm clear of the bar, so the spiral starts where the bar ends', () => {
|
||||||
|
for (const arm of MILKY_WAY_ARMS) {
|
||||||
|
for (let beta = arm.fromAzimuthDeg; beta <= arm.toAzimuthDeg; beta += 10) {
|
||||||
|
expect(armRadiusPc(arm, beta)).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
expect(arm.referenceRadiusPc).toBeGreaterThan(BAR_HALF_LENGTH_PC * 0.9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('brackets the Sun between the Sagittarius and Perseus arms at the Sun azimuth', () => {
|
||||||
|
// The one arrangement the local star field depends on: the solar neighbourhood sits in the
|
||||||
|
// gap between them, on the minor spur, not inside a major arm.
|
||||||
|
const sagittarius = MILKY_WAY_ARMS.find((arm) => arm.name.startsWith('Sagittarius'))!;
|
||||||
|
const perseus = MILKY_WAY_ARMS.find((arm) => arm.name === 'Perseus')!;
|
||||||
|
|
||||||
|
expect(armRadiusPc(sagittarius, 0)).toBeLessThan(SUN_GALACTOCENTRIC_RADIUS_PC);
|
||||||
|
expect(armRadiusPc(perseus, 0)).toBeGreaterThan(SUN_GALACTOCENTRIC_RADIUS_PC);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the Orion Spur past the Sun, close to the Sun radius', () => {
|
||||||
|
expect(Math.abs(armRadiusPc(ORION_SPUR, 0) - SUN_GALACTOCENTRIC_RADIUS_PC)).toBeLessThan(600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GALACTIC_LANDMARKS', () => {
|
||||||
|
it('has a unique id per landmark', () => {
|
||||||
|
const ids = GALACTIC_LANDMARKS.map((landmark) => landmark.id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names one landmark per modelled arm, plus the centre, the Sun and the spur', () => {
|
||||||
|
expect(GALACTIC_LANDMARKS).toHaveLength(MILKY_WAY_ARMS.length + 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts Sol back at the origin', () => {
|
||||||
|
const sol = GALACTIC_LANDMARKS.find((landmark) => landmark.id === 'sol')!;
|
||||||
|
const position = landmarkPositionPc(sol);
|
||||||
|
// The Sun is the origin of the scene's coordinates, give or take its height above the plane,
|
||||||
|
// which the landmark deliberately drops so the label sits on the map rather than off it.
|
||||||
|
expect(Math.hypot(position.x, position.y, position.z)).toBeCloseTo(SUN_HEIGHT_ABOVE_MIDPLANE_PC, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every landmark inside the modelled disc', () => {
|
||||||
|
for (const landmark of GALACTIC_LANDMARKS) {
|
||||||
|
expect(length(landmarkPositionPc(landmark))).toBeLessThan(DISC_RADIUS_PC * 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { CartesianCoordinates, raDegDecDistanceToXyz } from './coordinates';
|
||||||
|
|
||||||
|
const DEG_TO_RAD = Math.PI / 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural model of the Milky Way, and the rotation that carries it into the equatorial
|
||||||
|
* frame the rest of the scene works in.
|
||||||
|
*
|
||||||
|
* **This is a model, not a catalogue.** Every other dataset in this app is measured: HYG gives
|
||||||
|
* real parallaxes, Horizons real ephemerides, the Exoplanet Archive real orbits. The Galaxy is
|
||||||
|
* different — we sit inside it, and dust blocks the view across the disc, so no catalogue holds
|
||||||
|
* the positions of its stars. What *is* measured is its skeleton: the distance to the centre,
|
||||||
|
* the tilt of the disc against the sky, and the radius/pitch/azimuth of each spiral arm from
|
||||||
|
* maser parallaxes. Those measurements are the constants below; the individual particles the
|
||||||
|
* renderer scatters around them are illustrative, and labelled as such in the UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direction of the galactic centre (Sgr A*) and the north galactic pole, in equatorial J2000.
|
||||||
|
* These two directions are what tie the model to the sky: everything else is built in galactic
|
||||||
|
* coordinates and rotated through them.
|
||||||
|
*/
|
||||||
|
export const GALACTIC_CENTRE_RA_DEG = 266.4051;
|
||||||
|
export const GALACTIC_CENTRE_DEC_DEG = -28.936175;
|
||||||
|
export const NORTH_GALACTIC_POLE_RA_DEG = 192.85948;
|
||||||
|
export const NORTH_GALACTIC_POLE_DEC_DEG = 27.12825;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sun-to-galactic-centre distance, in parsecs (GRAVITY Collaboration 2019, from the orbit of
|
||||||
|
* S2 around Sgr A*), and the Sun's height above the disc midplane.
|
||||||
|
*/
|
||||||
|
export const SUN_GALACTOCENTRIC_RADIUS_PC = 8178;
|
||||||
|
export const SUN_HEIGHT_ABOVE_MIDPLANE_PC = 20.8;
|
||||||
|
|
||||||
|
/** Rough visible extent of the stellar disc, and its exponential scale length/height. */
|
||||||
|
export const DISC_RADIUS_PC = 16000;
|
||||||
|
export const DISC_SCALE_LENGTH_PC = 2600;
|
||||||
|
export const DISC_SCALE_HEIGHT_PC = 300;
|
||||||
|
|
||||||
|
/** Boxy/peanut bulge and bar: half-length, half-width, half-thickness, and orientation. */
|
||||||
|
export const BAR_HALF_LENGTH_PC = 4200;
|
||||||
|
export const BAR_HALF_WIDTH_PC = 1300;
|
||||||
|
export const BAR_HALF_THICKNESS_PC = 900;
|
||||||
|
/** Angle between the bar's long axis and the Sun-centre line, near end at positive longitude. */
|
||||||
|
export const BAR_POSITION_ANGLE_DEG = 25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A spiral arm as a logarithmic spiral: `R(beta) = referenceRadiusPc * exp(-(beta -
|
||||||
|
* referenceAzimuthDeg) * tan(pitchAngleDeg))`.
|
||||||
|
*
|
||||||
|
* `beta` is the galactocentric azimuth measured from the Sun's direction, increasing in the
|
||||||
|
* direction of galactic rotation — the convention used by the maser-parallax surveys these
|
||||||
|
* figures approximate (Reid et al. 2019). Values are rounded; they place each arm on the right
|
||||||
|
* side of the Sun at the right pitch, which is what the view needs, and are not a substitute
|
||||||
|
* for the published fits.
|
||||||
|
*/
|
||||||
|
export interface SpiralArm {
|
||||||
|
readonly name: string;
|
||||||
|
readonly referenceRadiusPc: number;
|
||||||
|
readonly referenceAzimuthDeg: number;
|
||||||
|
readonly pitchAngleDeg: number;
|
||||||
|
/** Azimuth range to trace the arm over, in the same `beta` convention. */
|
||||||
|
readonly fromAzimuthDeg: number;
|
||||||
|
readonly toAzimuthDeg: number;
|
||||||
|
/**
|
||||||
|
* Where along the arm to anchor its name. Staggered between arms on purpose: anchoring them
|
||||||
|
* all at one azimuth stacks five labels on the same radial line, which is unreadable.
|
||||||
|
*/
|
||||||
|
readonly labelAzimuthDeg: number;
|
||||||
|
/** Half-width of the star-forming ridge, in parsecs — how far particles scatter off the spine. */
|
||||||
|
readonly widthPc: number;
|
||||||
|
/** Relative particle density, so the two grand-design arms read as the dominant pair. */
|
||||||
|
readonly weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MILKY_WAY_ARMS: readonly SpiralArm[] = [
|
||||||
|
{ name: 'Norma', referenceRadiusPc: 4460, referenceAzimuthDeg: 18, pitchAngleDeg: 1, fromAzimuthDeg: -20, toAzimuthDeg: 200, labelAzimuthDeg: 120, widthPc: 500, weight: 0.7 },
|
||||||
|
{ name: 'Scutum–Centaurus', referenceRadiusPc: 4910, referenceAzimuthDeg: 23, pitchAngleDeg: 12.1, fromAzimuthDeg: -30, toAzimuthDeg: 290, labelAzimuthDeg: 70, widthPc: 700, weight: 1 },
|
||||||
|
{ name: 'Sagittarius–Carina', referenceRadiusPc: 6040, referenceAzimuthDeg: 24, pitchAngleDeg: 17.1, fromAzimuthDeg: -40, toAzimuthDeg: 230, labelAzimuthDeg: -20, widthPc: 650, weight: 0.95 },
|
||||||
|
{ name: 'Perseus', referenceRadiusPc: 8870, referenceAzimuthDeg: 40, pitchAngleDeg: 10.3, fromAzimuthDeg: -60, toAzimuthDeg: 220, labelAzimuthDeg: 90, widthPc: 700, weight: 1 },
|
||||||
|
{ name: 'Outer', referenceRadiusPc: 12240, referenceAzimuthDeg: 18, pitchAngleDeg: 3, fromAzimuthDeg: -60, toAzimuthDeg: 200, labelAzimuthDeg: 30, widthPc: 800, weight: 0.55 }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Orion Spur — the minor arm the Sun sits in, which is why the local star field is not
|
||||||
|
* empty. Short, so it is described by its own azimuth span rather than the full sweep above.
|
||||||
|
*/
|
||||||
|
export const ORION_SPUR: SpiralArm = {
|
||||||
|
name: 'Orion Spur',
|
||||||
|
referenceRadiusPc: 8260,
|
||||||
|
referenceAzimuthDeg: 8.9,
|
||||||
|
pitchAngleDeg: 11.4,
|
||||||
|
fromAzimuthDeg: -25,
|
||||||
|
toAzimuthDeg: 45,
|
||||||
|
labelAzimuthDeg: 20,
|
||||||
|
widthPc: 400,
|
||||||
|
weight: 0.45
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Galactocentric radius of a point on an arm at azimuth `betaDeg`, in parsecs.
|
||||||
|
* Diverges for large negative azimuths by construction — a logarithmic spiral has no
|
||||||
|
* outer end — so callers trace it only over the arm's own azimuth range.
|
||||||
|
*/
|
||||||
|
export function armRadiusPc(arm: SpiralArm, betaDeg: number): number {
|
||||||
|
return arm.referenceRadiusPc * Math.exp(-(betaDeg - arm.referenceAzimuthDeg) * DEG_TO_RAD * Math.tan(arm.pitchAngleDeg * DEG_TO_RAD));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts galactocentric cylindrical coordinates into heliocentric galactic Cartesian
|
||||||
|
* coordinates in parsecs: +X toward the galactic centre, +Y toward galactic longitude 90
|
||||||
|
* (the direction of the Sun's rotation about the centre), +Z toward the north galactic pole.
|
||||||
|
*
|
||||||
|
* `heightPc` is measured from the disc midplane, not from the Sun — so a point with
|
||||||
|
* `heightPc = 0` comes out at `z = -SUN_HEIGHT_ABOVE_MIDPLANE_PC`, since the Sun sits a little
|
||||||
|
* above the plane it orbits in.
|
||||||
|
*/
|
||||||
|
export function galactocentricToHeliocentricGalactic(radiusPc: number, azimuthDeg: number, heightPc: number): CartesianCoordinates {
|
||||||
|
const beta = azimuthDeg * DEG_TO_RAD;
|
||||||
|
return {
|
||||||
|
x: SUN_GALACTOCENTRIC_RADIUS_PC - radiusPc * Math.cos(beta),
|
||||||
|
y: radiusPc * Math.sin(beta),
|
||||||
|
z: heightPc - SUN_HEIGHT_ABOVE_MIDPLANE_PC
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(v: CartesianCoordinates): CartesianCoordinates {
|
||||||
|
const length = Math.hypot(v.x, v.y, v.z);
|
||||||
|
return { x: v.x / length, y: v.y / length, z: v.z / length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cross(a: CartesianCoordinates, b: CartesianCoordinates): CartesianCoordinates {
|
||||||
|
return { x: a.y * b.z - a.z * b.y, y: a.z * b.x - a.x * b.z, z: a.x * b.y - a.y * b.x };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The galactic frame's basis vectors, expressed in the equatorial frame.
|
||||||
|
*
|
||||||
|
* Built from the two catalogue directions above, which are given to finite precision and so are
|
||||||
|
* not exactly perpendicular. The pole is taken as authoritative for "up" and the centre
|
||||||
|
* direction is re-orthogonalised against it, which keeps the result an exact rotation — a basis
|
||||||
|
* that is a fraction of an arcsecond off square would shear the whole model.
|
||||||
|
*/
|
||||||
|
const GALACTIC_POLE_EQUATORIAL = raDegDecDistanceToXyz(NORTH_GALACTIC_POLE_RA_DEG, NORTH_GALACTIC_POLE_DEC_DEG, 1);
|
||||||
|
const GALACTIC_CENTRE_EQUATORIAL = raDegDecDistanceToXyz(GALACTIC_CENTRE_RA_DEG, GALACTIC_CENTRE_DEC_DEG, 1);
|
||||||
|
const GALACTIC_Z = normalize(GALACTIC_POLE_EQUATORIAL);
|
||||||
|
const GALACTIC_Y = normalize(cross(GALACTIC_Z, GALACTIC_CENTRE_EQUATORIAL));
|
||||||
|
const GALACTIC_X = cross(GALACTIC_Y, GALACTIC_Z);
|
||||||
|
|
||||||
|
export const GALACTIC_BASIS_EQUATORIAL = {
|
||||||
|
x: GALACTIC_X,
|
||||||
|
y: GALACTIC_Y,
|
||||||
|
z: GALACTIC_Z
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Rotates a heliocentric galactic vector into the scene's equatorial frame. */
|
||||||
|
export function galacticToEquatorial(v: CartesianCoordinates): CartesianCoordinates {
|
||||||
|
return {
|
||||||
|
x: GALACTIC_X.x * v.x + GALACTIC_Y.x * v.y + GALACTIC_Z.x * v.z,
|
||||||
|
y: GALACTIC_X.y * v.x + GALACTIC_Y.y * v.y + GALACTIC_Z.y * v.z,
|
||||||
|
z: GALACTIC_X.z * v.x + GALACTIC_Y.z * v.y + GALACTIC_Z.z * v.z
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rotates an equatorial vector into heliocentric galactic coordinates (the inverse rotation). */
|
||||||
|
export function equatorialToGalactic(v: CartesianCoordinates): CartesianCoordinates {
|
||||||
|
return {
|
||||||
|
x: GALACTIC_X.x * v.x + GALACTIC_X.y * v.y + GALACTIC_X.z * v.z,
|
||||||
|
y: GALACTIC_Y.x * v.x + GALACTIC_Y.y * v.y + GALACTIC_Y.z * v.z,
|
||||||
|
z: GALACTIC_Z.x * v.x + GALACTIC_Z.y * v.y + GALACTIC_Z.z * v.z
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The galactic centre's position in the scene's equatorial frame, in parsecs from the Sun. */
|
||||||
|
export function galacticCentrePositionPc(): CartesianCoordinates {
|
||||||
|
return galacticToEquatorial(galactocentricToHeliocentricGalactic(0, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named landmarks worth pinning in the galactic view. Positions are derived from the same
|
||||||
|
* structural constants as the model, so a label always sits on the feature it names.
|
||||||
|
*/
|
||||||
|
export interface GalacticLandmark {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
/** Galactocentric radius/azimuth, matching {@link galactocentricToHeliocentricGalactic}. */
|
||||||
|
readonly radiusPc: number;
|
||||||
|
readonly azimuthDeg: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GALACTIC_LANDMARKS: readonly GalacticLandmark[] = [
|
||||||
|
{ id: 'sgr-a', name: 'Sagittarius A*', radiusPc: 0, azimuthDeg: 0 },
|
||||||
|
{ id: 'sol', name: 'Sol', radiusPc: SUN_GALACTOCENTRIC_RADIUS_PC, azimuthDeg: 0 },
|
||||||
|
...MILKY_WAY_ARMS.map((arm) => ({
|
||||||
|
id: `arm-${arm.name.toLowerCase().replace(/[^a-z]+/g, '-')}`,
|
||||||
|
name: `${arm.name} Arm`,
|
||||||
|
radiusPc: armRadiusPc(arm, arm.labelAzimuthDeg),
|
||||||
|
azimuthDeg: arm.labelAzimuthDeg
|
||||||
|
})),
|
||||||
|
{ id: 'arm-orion-spur', name: 'Orion Spur', radiusPc: armRadiusPc(ORION_SPUR, ORION_SPUR.labelAzimuthDeg), azimuthDeg: ORION_SPUR.labelAzimuthDeg }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** A landmark's position in the scene's equatorial frame, in parsecs from the Sun. */
|
||||||
|
export function landmarkPositionPc(landmark: GalacticLandmark): CartesianCoordinates {
|
||||||
|
return galacticToEquatorial(galactocentricToHeliocentricGalactic(landmark.radiusPc, landmark.azimuthDeg, 0));
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
export type ViewLevel = 'galaxy' | 'system';
|
/**
|
||||||
|
* The map's zoom levels, outermost first.
|
||||||
|
*
|
||||||
|
* `galactic` is the whole Milky Way; `galaxy` is the catalogued solar neighbourhood inside it
|
||||||
|
* (the level the app opens on); `system` is one star's planets. The first two share a coordinate
|
||||||
|
* space and are told apart by how far the camera has pulled back, so the scene reports which one
|
||||||
|
* it is in rather than being commanded into it.
|
||||||
|
*/
|
||||||
|
export type ViewLevel = 'galactic' | 'galaxy' | 'system';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide navigation state: which zoom level is active and what's currently selected.
|
* App-wide navigation state: which zoom level is active and what's currently selected.
|
||||||
|
|||||||
@@ -26,6 +26,56 @@ body {
|
|||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- HUD chrome ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Angled top-left/bottom-right corners, a thin lit edge and a blurred dark fill: the panel
|
||||||
|
* shape the whole heads-up display is built from. Written here rather than as utilities
|
||||||
|
* because `clip-path` and the layered border need to travel together. */
|
||||||
|
.hud-panel {
|
||||||
|
position: relative;
|
||||||
|
background: color-mix(in srgb, var(--color-panel) 78%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
clip-path: polygon(14px 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%, 0 14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The lit inner hairline that reads as a bevel on the two square corners. */
|
||||||
|
.hud-panel::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 3px;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Corner brackets around the whole viewport, drawn as four gradients so the element stays a
|
||||||
|
* single node with no children. */
|
||||||
|
.hud-frame {
|
||||||
|
pointer-events: none;
|
||||||
|
--bracket: color-mix(in srgb, var(--color-accent) 45%, transparent);
|
||||||
|
--arm-long: 76px;
|
||||||
|
--arm-thin: 1px;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket)),
|
||||||
|
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket)),
|
||||||
|
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket)),
|
||||||
|
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket));
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size:
|
||||||
|
var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long), var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long),
|
||||||
|
var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long), var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long);
|
||||||
|
background-position:
|
||||||
|
18px 18px, 18px 18px, right 18px top 18px, right 18px top 18px, 18px bottom 18px, 18px bottom 18px, right 18px bottom 18px,
|
||||||
|
right 18px bottom 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Darkens the edges of the frame so the map reads as a lit display rather than a flat page. */
|
||||||
|
.hud-vignette {
|
||||||
|
pointer-events: none;
|
||||||
|
background: radial-gradient(ellipse at center, transparent 45%, color-mix(in srgb, var(--color-void) 72%, transparent) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
/* Star name labels rendered by CSS2DRenderer (see StarLabelOverlay). These live outside
|
/* Star name labels rendered by CSS2DRenderer (see StarLabelOverlay). These live outside
|
||||||
* Angular's view encapsulation as plain DOM nodes, so they're styled with Tailwind utility
|
* Angular's view encapsulation as plain DOM nodes, so they're styled with Tailwind utility
|
||||||
* classes assigned directly in TypeScript rather than a scoped component stylesheet. */
|
* classes assigned directly in TypeScript rather than a scoped component stylesheet. */
|
||||||
|
|||||||
Reference in New Issue
Block a user