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:
@@ -7,10 +7,14 @@ import { LabeledPoint } from './star-label-overlay';
|
||||
/**
|
||||
* 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
|
||||
* star field, beyond the camera's 2000 pc orbit limit so it can never be flown through, and
|
||||
* close enough that even the far side of the shell (2000 + 2500 = 4500 pc) stays inside the
|
||||
* 5000 pc far plane rather than being clipped away.
|
||||
* Chosen to sit clear of the local star field it is a backdrop for: well outside its 50 pc
|
||||
* radius, and close enough that the far side of the shell stays inside the local view's 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;
|
||||
|
||||
@@ -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
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
for (const material of this.materials.values()) {
|
||||
material.dispose();
|
||||
|
||||
@@ -211,7 +211,11 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
const component = fixture.componentInstance as unknown as { galaxyGroup: THREE.Group; systemGroup: THREE.Group };
|
||||
expect(component.galaxyGroup.visible).toBe(true);
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -230,6 +234,59 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
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 () => {
|
||||
navigationStore.selectStar(SUN.id);
|
||||
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 * as THREE from 'three/webgpu';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
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 { EngineService } from '../../core/engine/engine.service';
|
||||
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 { loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
|
||||
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 { 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 { HudReadout, StarmapHudComponent } from './starmap-hud.component';
|
||||
import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer';
|
||||
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
|
||||
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
||||
@@ -28,6 +32,11 @@ const SUN_GLOW_SCALE = 3.2;
|
||||
const LABEL_MAX_DISTANCE_PC = 20;
|
||||
/** Caps how many labels are shown at once, to keep the DOM light. */
|
||||
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
|
||||
* 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. */
|
||||
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_NEAR_PC = 0.01;
|
||||
const GALAXY_FAR_PC = 5000;
|
||||
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. */
|
||||
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_FAR_AU = 20000;
|
||||
const SYSTEM_MIN_DISTANCE_AU = 0.05;
|
||||
@@ -61,6 +108,29 @@ const APPROACH_DURATION_SECONDS = 1.0;
|
||||
const SETTLE_DURATION_SECONDS = 0.9;
|
||||
const EXIT_DURATION_SECONDS = 0.9;
|
||||
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
|
||||
@@ -72,22 +142,21 @@ const RETURN_DURATION_SECONDS = 1.1;
|
||||
@Component({
|
||||
selector: 'app-galaxy-system-scene',
|
||||
providers: [EngineService],
|
||||
imports: [StarmapHudComponent],
|
||||
template: `
|
||||
<div class="relative h-full w-full">
|
||||
<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>
|
||||
@if (navigationStore.viewLevel() === 'system') {
|
||||
<button
|
||||
type="button"
|
||||
(click)="exitSystem()"
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<path d="M15 6l-6 6 6 6" />
|
||||
</svg>
|
||||
Galaxy
|
||||
</button>
|
||||
}
|
||||
<app-starmap-hud
|
||||
[level]="navigationStore.viewLevel()"
|
||||
[eyebrow]="hudEyebrow()"
|
||||
[title]="hudTitle()"
|
||||
[subtitle]="hudSubtitle()"
|
||||
[readouts]="hudReadouts()"
|
||||
[note]="hudNote()"
|
||||
[range]="hudRange()"
|
||||
(levelSelected)="goToLevel($event)"
|
||||
/>
|
||||
</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. */
|
||||
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 rig?: CameraRigController;
|
||||
private starField?: StarFieldRenderer;
|
||||
private deepSky?: DeepSkyRenderer;
|
||||
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 stars: readonly 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 ready = 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. */
|
||||
private currentStarId: number | null = null;
|
||||
@@ -151,6 +237,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.controls?.dispose();
|
||||
this.starField?.dispose();
|
||||
this.deepSky?.dispose();
|
||||
this.milkyWay?.dispose();
|
||||
this.galacticGrid?.dispose();
|
||||
this.localGrid?.dispose();
|
||||
this.tethers?.dispose();
|
||||
this.labelOverlay?.dispose();
|
||||
this.systemRenderer?.dispose();
|
||||
(this.starMarker?.material as THREE.Material | undefined)?.dispose();
|
||||
@@ -160,8 +250,34 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.engine.dispose();
|
||||
}
|
||||
|
||||
exitSystem(): void {
|
||||
this.navigationStore.selectStar(null);
|
||||
/**
|
||||
* 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);
|
||||
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> {
|
||||
@@ -212,6 +328,33 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.starField = new StarFieldRenderer(stars, positions);
|
||||
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) {
|
||||
this.deepSky = new DeepSkyRenderer(deepSky);
|
||||
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
|
||||
// and pinned over the system — the whole point of clearing them on the swap.
|
||||
if (this.galaxyGroup.visible) {
|
||||
this.labelUpdateAccumulator += deltaSeconds;
|
||||
if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) {
|
||||
this.labelUpdateAccumulator = 0;
|
||||
// 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;
|
||||
if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) {
|
||||
this.labelUpdateAccumulator = 0;
|
||||
if (this.galaxyGroup.visible) {
|
||||
this.updateLabels(camera);
|
||||
}
|
||||
this.updateHud(camera);
|
||||
}
|
||||
|
||||
if (this.systemGroup.visible) {
|
||||
@@ -256,9 +406,60 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
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 {
|
||||
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 candidates: Array<{ star: StarRecord; distanceSq: number }> = [];
|
||||
@@ -273,8 +474,96 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => a.distanceSq - b.distanceSq);
|
||||
const starLabels = candidates.slice(0, LABEL_MAX_COUNT).map((candidate) => candidate.star);
|
||||
this.labelOverlay?.update([...starLabels, ...this.deepSkyLabels]);
|
||||
// Individual star names mean nothing once the whole Galaxy is in frame — at that range the
|
||||
// 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. */
|
||||
@@ -354,6 +643,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
private finishTransition(): void {
|
||||
this.busy = false;
|
||||
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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user