Add star-map Angular app, ETL pipeline, and caveman plugin

Angular 3D star map (galaxy/system/body views, Three.js rendering,
navigation store) plus the NASA ETL tooling that builds the star,
exoplanet and solar-system datasets, Playwright e2e suite, and the
cs:caveman Claude Code plugin (command, agent, skill).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
2026-08-03 16:50:10 +02:00
parent 1e1b58b0e9
commit d7e8ea1d4d
99 changed files with 5513 additions and 1 deletions
+11
View File
@@ -0,0 +1,11 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes)
]
};
+2
View File
@@ -0,0 +1,2 @@
<app-search></app-search>
<router-outlet></router-outlet>
+12
View File
@@ -0,0 +1,12 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./features/galaxy-system/galaxy-system-scene.component').then((m) => m.GalaxySystemSceneComponent)
},
{
path: 'body/:id',
loadComponent: () => import('./features/body-detail/body-detail-scene.component').then((m) => m.BodyDetailSceneComponent)
}
];
+5
View File
@@ -0,0 +1,5 @@
:host {
display: block;
width: 100%;
height: 100%;
}
+22
View File
@@ -0,0 +1,22 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render a router outlet', () => {
const fixture = TestBed.createComponent(App);
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('router-outlet')).not.toBeNull();
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { SearchComponent } from './features/search/search.component';
@Component({
selector: 'app-root',
imports: [RouterOutlet, SearchComponent],
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App {}
+50
View File
@@ -0,0 +1,50 @@
import { Injectable } from '@angular/core';
import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model';
export interface StarField {
stars: StarRecord[];
/** Positions in parsecs, in the same order as `stars`, packed as [x0,y0,z0,x1,y1,z1,...]. */
positions: Float32Array;
}
/**
* Loads the ETL-generated static assets (`src/assets/data/*`, served at `/assets/data/*`).
* Each dataset is fetched at most once per app session and cached in memory.
*/
@Injectable({ providedIn: 'root' })
export class DataLoaderService {
private starFieldPromise?: Promise<StarField>;
private bodiesPromise?: Promise<BodyRecord[]>;
private exoplanetsPromise?: Promise<ExoplanetRecord[]>;
loadStars(): Promise<StarField> {
this.starFieldPromise ??= this.fetchStars();
return this.starFieldPromise;
}
loadBodies(): Promise<BodyRecord[]> {
this.bodiesPromise ??= this.fetchJson<BodyRecord[]>('assets/data/bodies.json');
return this.bodiesPromise;
}
loadExoplanets(): Promise<ExoplanetRecord[]> {
this.exoplanetsPromise ??= this.fetchJson<ExoplanetRecord[]>('assets/data/exoplanets.json');
return this.exoplanetsPromise;
}
private async fetchStars(): Promise<StarField> {
const [stars, buffer] = await Promise.all([
fetch('assets/data/stars-index.json').then((response) => response.json() as Promise<StarRecord[]>),
fetch('assets/data/stars.bin').then((response) => response.arrayBuffer())
]);
return { stars, positions: new Float32Array(buffer) };
}
private fetchJson<T>(url: string): Promise<T> {
return fetch(url).then((response) => response.json() as Promise<T>);
}
}
+148
View File
@@ -0,0 +1,148 @@
import { Injectable, NgZone } from '@angular/core';
import * as THREE from 'three/webgpu';
export type EngineTickCallback = (deltaSeconds: number, elapsedSeconds: number) => void;
/**
* Owns the Three.js WebGPURenderer (with automatic WebGL2 fallback), the base scene/camera,
* and the render loop. The loop always runs outside Angular's zone so per-frame work never
* triggers change detection.
*
* Not provided at root: each canvas host component provides its own instance
* (via its `providers` array) so independent scenes (e.g. galaxy/system vs. body detail)
* never share a renderer.
*/
@Injectable()
export class EngineService {
private readonly clock = new THREE.Clock(false);
private readonly tickCallbacks = new Set<EngineTickCallback>();
private canvas?: HTMLCanvasElement;
private renderer?: THREE.WebGPURenderer;
private scene?: THREE.Scene;
private camera?: THREE.PerspectiveCamera;
private running = false;
constructor(private readonly ngZone: NgZone) {}
get isInitialized(): boolean {
return !!this.renderer;
}
getScene(): THREE.Scene {
return this.requireInitialized(this.scene);
}
getCamera(): THREE.PerspectiveCamera {
return this.requireInitialized(this.camera);
}
getRenderer(): THREE.WebGPURenderer {
return this.requireInitialized(this.renderer);
}
/**
* Creates the renderer/scene/camera against the given canvas. Must be called once per canvas.
*/
async init(canvas: HTMLCanvasElement): Promise<void> {
this.canvas = canvas;
this.renderer = new THREE.WebGPURenderer({ canvas, antialias: true });
await this.renderer.init();
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
this.camera.position.set(0, 0, 5);
const { width, height } = this.canvasSize();
this.resize(width, height);
}
/**
* Registers a callback invoked once per rendered frame, before the scene is drawn.
* Returns an unsubscribe function.
*/
onTick(callback: EngineTickCallback): () => void {
this.tickCallbacks.add(callback);
return () => this.tickCallbacks.delete(callback);
}
/**
* Starts the render loop outside Angular's zone.
*/
start(): void {
if (this.running) {
return;
}
this.running = true;
this.clock.start();
this.ngZone.runOutsideAngular(() => {
this.requireInitialized(this.renderer).setAnimationLoop(() => this.tick());
});
}
/**
* Stops the render loop without disposing any resources.
*/
stop(): void {
if (!this.running) {
return;
}
this.running = false;
this.clock.stop();
this.renderer?.setAnimationLoop(null);
}
/**
* Updates the camera aspect ratio and renderer drawing buffer size.
*/
resize(width: number, height: number): void {
if (!this.renderer || !this.camera || width <= 0 || height <= 0) {
return;
}
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height, false);
}
/**
* Stops the loop and releases GPU resources. Call when the canvas host is destroyed.
*/
dispose(): void {
this.stop();
this.tickCallbacks.clear();
this.renderer?.dispose();
this.renderer = undefined;
this.scene = undefined;
this.camera = undefined;
this.canvas = undefined;
}
private tick(): void {
const deltaSeconds = this.clock.getDelta();
const elapsedSeconds = this.clock.getElapsedTime();
for (const callback of this.tickCallbacks) {
callback(deltaSeconds, elapsedSeconds);
}
this.requireInitialized(this.renderer).render(this.requireInitialized(this.scene), this.requireInitialized(this.camera));
}
private canvasSize(): { width: number; height: number } {
const canvas = this.requireInitialized(this.canvas);
return {
width: canvas.clientWidth || 1,
height: canvas.clientHeight || 1
};
}
private requireInitialized<T>(value: T | undefined): T {
if (value === undefined) {
throw new Error('EngineService used before init() completed.');
}
return value;
}
}
@@ -0,0 +1,304 @@
import { AfterViewInit, Component, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { Subscription } from 'rxjs';
import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { DataLoaderService } from '../../core/data/data-loader.service';
import { EngineService } from '../../core/engine/engine.service';
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, proceduralBodyTexture, SATURN_RING_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model';
import { NavigationStore } from '../../shared/state/navigation.store';
import { BodyDetailViewModel } from './body-detail.model';
import { InfoPanelComponent } from './info-panel.component';
const KIND_COLORS: Record<BodyDetailViewModel['kind'], THREE.ColorRepresentation> = {
planet: 0x8cbfff,
moon: 0xbfbfbf,
dwarf: 0xccb28c,
exoplanet: 0xd966d9
};
/** Gas giants read as smoother/less rocky than terrestrial bodies under the same lighting rig. */
const GAS_GIANT_IDS = new Set(['jupiter', 'saturn', 'uranus', 'neptune']);
const GLOW_SCALE = 2.6;
/**
* Separate, focused route for inspecting a single planet/moon/exoplanet: its own scene/camera
* (via a dedicated `EngineService` instance, unrelated to the galaxy/system camera rig) plus
* an `InfoPanelComponent` showing its real NASA data. Reachable from system-view picking or
* search, and keeps `NavigationStore` in sync so returning to `/` resumes the correct system.
*
* Reacts to `ActivatedRoute.paramMap` (rather than reading the route snapshot once) because
* Angular's default route-reuse strategy keeps this component instance alive when navigating
* directly from one `/body/:id` to another (e.g. selecting a second search result while
* already on a body's detail page) — only the id param changes, not the route config.
*/
@Component({
selector: 'app-body-detail-scene',
providers: [EngineService],
imports: [InfoPanelComponent, RouterLink],
template: `
<div class="relative h-full w-full">
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
@if (viewModel()) {
<app-info-panel [body]="viewModel()!" />
} @else if (notFound()) {
<div class="absolute top-4 right-4 rounded-md border border-border bg-panel/80 p-5 font-body text-text backdrop-blur-md">
<p class="mb-2 text-sm">Couldn't find that body.</p>
<a routerLink="/" class="text-sm text-accent hover:underline">Back to the galaxy</a>
</div>
}
</div>
`
})
export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
private controls?: OrbitControls;
private scene?: THREE.Scene;
private planet?: THREE.Mesh;
private planetMaterial?: THREE.MeshStandardMaterial;
private ring?: THREE.Mesh;
private glow?: THREE.Sprite;
private resizeObserver?: ResizeObserver;
private unsubscribeTick?: () => void;
private paramSubscription?: Subscription;
private sceneReady = false;
private stars: readonly StarRecord[] = [];
private bodies: readonly BodyRecord[] = [];
private exoplanets: readonly ExoplanetRecord[] = [];
readonly viewModel = signal<BodyDetailViewModel | undefined>(undefined);
readonly notFound = signal(false);
constructor(
private readonly engine: EngineService,
private readonly dataLoader: DataLoaderService,
private readonly route: ActivatedRoute,
private readonly navigationStore: NavigationStore
) {}
ngAfterViewInit(): void {
void this.bootstrap();
}
ngOnDestroy(): void {
this.paramSubscription?.unsubscribe();
this.unsubscribeTick?.();
this.resizeObserver?.disconnect();
this.controls?.dispose();
this.planet?.geometry.dispose();
this.planetMaterial?.dispose();
this.disposeRing();
this.disposeGlow();
this.engine.dispose();
}
private async bootstrap(): Promise<void> {
const [stars, bodies, exoplanets] = await Promise.all([this.dataLoader.loadStars(), this.dataLoader.loadBodies(), this.dataLoader.loadExoplanets()]);
this.stars = stars.stars;
this.bodies = bodies;
this.exoplanets = exoplanets;
await this.initScene();
this.sceneReady = true;
this.paramSubscription = this.route.paramMap.subscribe((params) => {
this.showBody(params.get('id'));
});
}
private showBody(id: string | null): void {
if (!id) {
this.viewModel.set(undefined);
this.notFound.set(true);
return;
}
const body = this.bodies.find((candidate) => candidate.id === id);
const exoplanet = this.exoplanets.find((candidate) => candidate.id === id);
if (body) {
const hostStar = this.stars.find((star) => star.id === body.systemStarId);
this.viewModel.set({
id: body.id,
name: body.name,
kind: body.kind,
hostStarName: hostStar?.name ?? 'Unknown star',
radiusKm: body.radiusKm,
orbit: body.orbit
});
this.navigationStore.selectStar(body.systemStarId);
} else if (exoplanet) {
this.viewModel.set({
id: exoplanet.id,
name: exoplanet.name,
kind: 'exoplanet',
hostStarName: exoplanet.hostStarName,
radiusKm: exoplanet.radiusEarth ? exoplanet.radiusEarth * 6371 : undefined,
massEarth: exoplanet.massEarth,
discoveryYear: exoplanet.discoveryYear,
orbit: exoplanet.orbit
});
if (exoplanet.hostStarId !== null) {
this.navigationStore.selectStar(exoplanet.hostStarId);
}
} else {
this.viewModel.set(undefined);
this.notFound.set(true);
return;
}
this.notFound.set(false);
this.navigationStore.selectBody(id);
if (this.sceneReady) {
this.applyViewModelToScene();
}
}
private applyViewModelToScene(): void {
const viewModel = this.viewModel();
if (!viewModel || !this.planetMaterial) {
return;
}
const realTexturePath = bodyTexturePath(viewModel.id);
const texture = realTexturePath ? loadCachedTexture(realTexturePath) : proceduralBodyTexture(KIND_COLORS[viewModel.kind]);
this.planetMaterial.map = texture ?? null;
// A texture (real photo or procedural stand-in) supplies its own color; a plain white base
// keeps that color true instead of tinting it through `KIND_COLORS` a second time. If no
// texture is available at all (e.g. canvas rendering unsupported), fall back to the flat kind color.
this.planetMaterial.color.set(texture ? 0xffffff : KIND_COLORS[viewModel.kind]);
this.planetMaterial.roughness = GAS_GIANT_IDS.has(viewModel.id) ? 0.55 : 0.85;
this.planetMaterial.needsUpdate = true;
this.disposeRing();
this.disposeGlow();
if (this.scene) {
if (viewModel.id === 'saturn') {
this.ring = this.buildSaturnRing();
this.scene.add(this.ring);
}
const atmosphereColor = atmosphereColorFor(viewModel.id);
if (atmosphereColor !== undefined) {
this.glow = createGlowSprite(atmosphereColor, 1, GLOW_SCALE);
this.scene.add(this.glow);
}
}
}
/**
* Saturn's rings, built from a real ring-transparency map. `RingGeometry`'s default UVs wrap
* around the angle rather than the radius, so the per-vertex U is remapped to distance from
* center — the standard fix for sampling a radially-varying ring texture correctly.
*/
private buildSaturnRing(): THREE.Mesh {
const geometry = new THREE.RingGeometry(1.4, 2.6, 128, 1);
const position = geometry.attributes['position'];
const uv = geometry.attributes['uv'];
const vertex = new THREE.Vector3();
for (let i = 0; i < position.count; i++) {
vertex.fromBufferAttribute(position, i);
const radialFraction = THREE.MathUtils.clamp((vertex.length() - 1.4) / (2.6 - 1.4), 0, 1);
uv.setXY(i, radialFraction, 1);
}
const ringTexture = loadCachedTexture(SATURN_RING_TEXTURE_PATH);
const material = new THREE.MeshBasicMaterial({
map: ringTexture,
alphaMap: ringTexture,
transparent: true,
opacity: 0.85,
side: THREE.DoubleSide,
depthWrite: false
});
const ring = new THREE.Mesh(geometry, material);
ring.rotation.x = Math.PI / 2 - THREE.MathUtils.degToRad(17);
return ring;
}
private disposeRing(): void {
if (!this.ring) {
return;
}
this.scene?.remove(this.ring);
this.ring.geometry.dispose();
(this.ring.material as THREE.Material).dispose();
this.ring = undefined;
}
private disposeGlow(): void {
if (!this.glow) {
return;
}
this.scene?.remove(this.glow);
(this.glow.material as THREE.SpriteMaterial).dispose();
this.glow = undefined;
}
private async initScene(): Promise<void> {
const canvas = this.canvasRef().nativeElement;
try {
await this.engine.init(canvas);
} catch (error) {
console.error('Failed to initialize the 3D engine.', error);
return;
}
const scene = this.engine.getScene();
this.scene = scene;
applyMilkyWaySkybox(scene, MILKY_WAY_SKYBOX_PATH);
const camera = this.engine.getCamera();
camera.position.set(0, 0.6, 3);
camera.near = 0.05;
camera.far = 100;
camera.updateProjectionMatrix();
this.controls = new OrbitControls(camera, canvas);
this.controls.enableDamping = true;
this.controls.minDistance = 1.5;
this.controls.maxDistance = 12;
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
const sunLight = new THREE.DirectionalLight(0xfff4e0, 1.6);
sunLight.position.set(4, 3, 5);
scene.add(sunLight);
const geometry = new THREE.SphereGeometry(1, 64, 48);
const viewModel = this.viewModel();
this.planetMaterial = new THREE.MeshStandardMaterial({
color: viewModel ? KIND_COLORS[viewModel.kind] : 0xffffff,
roughness: 0.85,
metalness: 0.05
});
this.planet = new THREE.Mesh(geometry, this.planetMaterial);
scene.add(this.planet);
this.observeResize(canvas);
this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(deltaSeconds));
this.engine.start();
}
private tick(deltaSeconds: number): void {
this.controls?.update();
if (this.planet) {
this.planet.rotation.y += deltaSeconds * 0.08;
}
}
private observeResize(canvas: HTMLCanvasElement): void {
this.resizeObserver = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
this.engine.resize(width, height);
});
this.resizeObserver.observe(canvas);
}
}
@@ -0,0 +1,19 @@
import { OrbitalElements } from '../../shared/models/body.model';
export type BodyDetailKind = 'planet' | 'moon' | 'dwarf' | 'exoplanet';
/**
* Flattened view model combining the fields `InfoPanelComponent` displays, regardless of
* whether the selected body came from `bodies.json` (solar-system `BodyRecord`) or
* `exoplanets.json` (`ExoplanetRecord`) — the two sources report different subsets of data.
*/
export interface BodyDetailViewModel {
id: string;
name: string;
kind: BodyDetailKind;
hostStarName: string;
radiusKm?: number;
massEarth?: number;
discoveryYear?: number;
orbit: Partial<OrbitalElements>;
}
@@ -0,0 +1,79 @@
import { DecimalPipe } from '@angular/common';
import { Component, input } from '@angular/core';
import { Router } from '@angular/router';
import { BodyDetailViewModel } from './body-detail.model';
const KIND_LABELS: Record<BodyDetailViewModel['kind'], string> = {
planet: 'Planet',
moon: 'Moon',
dwarf: 'Dwarf planet',
exoplanet: 'Exoplanet'
};
/**
* Displays the real NASA data for the currently selected body/exoplanet: kind, physical
* size/mass, orbital elements, and (for exoplanets) discovery year. Presentational only —
* `BodyDetailSceneComponent` supplies the view model and owns navigation state.
*/
@Component({
selector: 'app-info-panel',
template: `
<div class="absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] rounded-md border border-border bg-panel/80 p-5 font-body text-text backdrop-blur-md">
<button
type="button"
(click)="goBack()"
class="mb-3 flex items-center gap-1.5 rounded-md border border-border bg-panel/60 px-3 py-1.5 text-xs tracking-wide text-muted uppercase 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>
System
</button>
<h1 class="mb-0.5 font-display text-lg font-semibold tracking-wide text-text">{{ body().name }}</h1>
<p class="mb-4 text-xs tracking-wide text-accent uppercase">{{ kindLabel() }} · {{ body().hostStarName }}</p>
<dl class="grid grid-cols-[auto_1fr] gap-y-1.5 gap-x-3 text-sm">
@if (body().radiusKm) {
<dt class="text-muted">Radius</dt>
<dd class="text-right text-text">{{ body().radiusKm | number: '1.0-1' }} km</dd>
}
@if (body().massEarth) {
<dt class="text-muted">Mass</dt>
<dd class="text-right text-text">{{ body().massEarth | number: '1.0-2' }} Earth masses</dd>
}
@if (body().orbit.semiMajorAxisAu) {
<dt class="text-muted">Semi-major axis</dt>
<dd class="text-right text-text">{{ body().orbit.semiMajorAxisAu | number: '1.0-4' }} AU</dd>
}
@if (body().orbit.eccentricity !== undefined) {
<dt class="text-muted">Eccentricity</dt>
<dd class="text-right text-text">{{ body().orbit.eccentricity | number: '1.0-4' }}</dd>
}
@if (body().orbit.inclinationDeg !== undefined) {
<dt class="text-muted">Inclination</dt>
<dd class="text-right text-text">{{ body().orbit.inclinationDeg | number: '1.0-2' }}°</dd>
}
@if (body().discoveryYear) {
<dt class="text-muted">Discovered</dt>
<dd class="text-right text-text">{{ body().discoveryYear }}</dd>
}
</dl>
</div>
`,
imports: [DecimalPipe]
})
export class InfoPanelComponent {
readonly body = input.required<BodyDetailViewModel>();
constructor(private readonly router: Router) {}
kindLabel(): string {
return KIND_LABELS[this.body().kind];
}
goBack(): void {
void this.router.navigate(['/']);
}
}
@@ -0,0 +1,174 @@
import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CameraPose, CameraRigController } from './camera-rig-controller';
function createRig(): { camera: THREE.PerspectiveCamera; controls: OrbitControls; rig: CameraRigController } {
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
camera.position.set(0, 0, 5);
const controls = new OrbitControls(camera, document.createElement('canvas'));
controls.target.set(0, 0, 0);
const rig = new CameraRigController(camera, controls);
return { camera, controls, rig };
}
function pose(position: [number, number, number], target: [number, number, number]): CameraPose {
return { position: new THREE.Vector3(...position), target: new THREE.Vector3(...target) };
}
describe('CameraRigController', () => {
let camera: THREE.PerspectiveCamera;
let controls: OrbitControls;
let rig: CameraRigController;
beforeEach(() => {
({ camera, controls, rig } = createRig());
});
it('is not animating and leaves controls enabled before any transition starts', () => {
expect(rig.isAnimating).toBe(false);
expect(controls.enabled).toBe(true);
});
it('flyTo starts an animation and disables the controls for its duration', () => {
rig.flyTo(pose([10, 0, 0], [1, 1, 1]), 2);
expect(rig.isAnimating).toBe(true);
expect(controls.enabled).toBe(false);
});
it('interpolates the camera position/target to the symmetric midpoint at the halfway point of the duration', () => {
rig.flyTo(pose([10, 0, 0], [2, 2, 2]), 2);
rig.update(1); // halfway through the 2s duration -> t = 0.5, eased(0.5) = 0.5 (symmetric midpoint)
expect(camera.position.x).toBeCloseTo(5, 6);
expect(camera.position.z).toBeCloseTo(2.5, 6);
expect(controls.target.x).toBeCloseTo(1, 6);
expect(rig.isAnimating).toBe(true);
});
it('eases the transition using the cubic in-out curve, not linearly', () => {
rig.flyTo(pose([8, 0, 0], [0, 0, 0]), 2);
rig.update(0.5); // t = 0.25 of the duration elapsed
// eased(0.25) = 4 * 0.25^3 = 0.0625 -> x = 0.5, well under the linear expectation of 25% (= 2).
expect(camera.position.x).toBeCloseTo(0.5, 6);
expect(camera.position.x).toBeLessThan(2);
});
it('calls lookAt toward the interpolated target on every update', () => {
const lookAtSpy = vi.spyOn(camera, 'lookAt');
rig.flyTo(pose([10, 0, 0], [2, 2, 2]), 2);
rig.update(1);
expect(lookAtSpy).toHaveBeenCalledWith(controls.target);
});
it('reaches the exact target pose, re-enables controls, and fires onComplete exactly once when the duration elapses', () => {
const onComplete = vi.fn();
rig.flyTo(pose([10, 0, 0], [2, 2, 2]), 2, onComplete);
rig.update(1); // halfway
expect(rig.isAnimating).toBe(true);
expect(onComplete).not.toHaveBeenCalled();
rig.update(1); // reaches the end exactly
expect(camera.position.x).toBeCloseTo(10, 9);
expect(camera.position.y).toBeCloseTo(0, 9);
expect(camera.position.z).toBeCloseTo(0, 9);
expect(controls.target.x).toBeCloseTo(2, 9);
expect(rig.isAnimating).toBe(false);
expect(controls.enabled).toBe(true);
expect(onComplete).toHaveBeenCalledTimes(1);
rig.update(1); // no active tween anymore -> no further calls, no throw
expect(onComplete).toHaveBeenCalledTimes(1);
});
it('clamps overshooting deltas so the camera never travels past the destination pose', () => {
const onComplete = vi.fn();
rig.flyTo(pose([10, 0, 0], [2, 2, 2]), 2, onComplete);
rig.update(100); // way more than the whole duration in a single frame
expect(camera.position.x).toBeCloseTo(10, 9);
expect(onComplete).toHaveBeenCalledTimes(1);
});
it('update() is a no-op while no transition is in flight', () => {
expect(() => rig.update(1)).not.toThrow();
expect(camera.position.x).toBeCloseTo(0, 9);
expect(camera.position.y).toBeCloseTo(0, 9);
expect(camera.position.z).toBeCloseTo(5, 9);
expect(controls.target.x).toBeCloseTo(0, 9);
expect(controls.target.y).toBeCloseTo(0, 9);
expect(controls.target.z).toBeCloseTo(0, 9);
expect(rig.isAnimating).toBe(false);
});
it('clones the pose passed to flyTo, so mutating the caller-owned vectors afterward does not affect the transition', () => {
const to = pose([10, 0, 0], [2, 2, 2]);
rig.flyTo(to, 2);
to.position.set(999, 999, 999);
to.target.set(999, 999, 999);
rig.update(2);
expect(camera.position.x).toBeCloseTo(10, 9);
expect(controls.target.x).toBeCloseTo(2, 9);
});
it('starting a new flyTo mid-transition replaces the old one, using the current interpolated pose as the new start', () => {
const firstOnComplete = vi.fn();
const secondOnComplete = vi.fn();
rig.flyTo(pose([10, 0, 0], [0, 0, 0]), 2, firstOnComplete);
rig.update(1); // halfway through the first tween: camera.position.x is now 5
const midPositionX = camera.position.x;
expect(midPositionX).toBeCloseTo(5, 6);
rig.flyTo(pose([0, 20, 0], [0, 0, 0]), 1, secondOnComplete);
// The new tween's "from" should be wherever the camera actually was, not the first tween's target.
rig.update(1); // completes the second (1s) tween
expect(camera.position.x).toBeCloseTo(0, 9);
expect(camera.position.y).toBeCloseTo(20, 9);
expect(firstOnComplete).not.toHaveBeenCalled();
expect(secondOnComplete).toHaveBeenCalledTimes(1);
expect(rig.isAnimating).toBe(false);
});
it('setImmediate jumps the camera/target with no easing and without requiring update()', () => {
rig.setImmediate(pose([3, 4, 5], [1, 1, 1]));
expect(camera.position.toArray()).toEqual([3, 4, 5]);
expect(controls.target.toArray()).toEqual([1, 1, 1]);
expect(rig.isAnimating).toBe(false);
});
it('setImmediate points the camera at the new target', () => {
const lookAtSpy = vi.spyOn(camera, 'lookAt');
rig.setImmediate(pose([3, 4, 5], [1, 1, 1]));
expect(lookAtSpy).toHaveBeenCalledWith(new THREE.Vector3(1, 1, 1));
});
it('treats a zero-second duration as effectively instantaneous on the next update, without dividing by zero', () => {
const onComplete = vi.fn();
rig.flyTo(pose([10, 0, 0], [2, 2, 2]), 0, onComplete);
rig.update(0.001);
expect(Number.isFinite(camera.position.x)).toBe(true);
expect(camera.position.x).toBeCloseTo(10, 9);
expect(onComplete).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,81 @@
import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
export interface CameraPose {
position: THREE.Vector3;
target: THREE.Vector3;
}
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
/**
* Drives smooth camera-position/look-at tweens between two poses. Deliberately unaware of
* parsecs vs. AU: the galaxy-to-system transition is built from two of these tweens (one per
* unit space) with a "floating-origin" recenter — an instantaneous {@link setImmediate} jump
* that swaps which group is visible and which unit scale the camera/controls operate in —
* spliced in between them by the caller (`GalaxySystemSceneComponent`).
*/
export class CameraRigController {
private active?: {
from: CameraPose;
to: CameraPose;
duration: number;
elapsed: number;
onComplete?: () => void;
};
constructor(
private readonly camera: THREE.PerspectiveCamera,
private readonly controls: OrbitControls
) {}
get isAnimating(): boolean {
return !!this.active;
}
/** Starts (replacing any in-flight tween) an eased camera-pose animation. */
flyTo(to: CameraPose, durationSeconds: number, onComplete?: () => void): void {
this.controls.enabled = false;
this.active = {
from: { position: this.camera.position.clone(), target: this.controls.target.clone() },
to: { position: to.position.clone(), target: to.target.clone() },
duration: Math.max(durationSeconds, 0.001),
elapsed: 0,
onComplete
};
}
/**
* Instantly places the camera/controls-target with no animation — the floating-origin
* recenter jump performed right after swapping which unit space/group is visible.
*/
setImmediate(pose: CameraPose): void {
this.camera.position.copy(pose.position);
this.controls.target.copy(pose.target);
this.camera.lookAt(pose.target);
}
/** Advances any in-flight tween. Call once per rendered frame. */
update(deltaSeconds: number): void {
if (!this.active) {
return;
}
this.active.elapsed += deltaSeconds;
const t = Math.min(this.active.elapsed / this.active.duration, 1);
const eased = easeInOutCubic(t);
this.camera.position.lerpVectors(this.active.from.position, this.active.to.position, eased);
this.controls.target.lerpVectors(this.active.from.target, this.active.to.target, eased);
this.camera.lookAt(this.controls.target);
if (t >= 1) {
const { onComplete } = this.active;
this.active = undefined;
this.controls.enabled = true;
onComplete?.();
}
}
}
@@ -0,0 +1,228 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import * as THREE from 'three/webgpu';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DataLoaderService, StarField } from '../../core/data/data-loader.service';
import { EngineService, EngineTickCallback } from '../../core/engine/engine.service';
import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model';
import { NavigationStore } from '../../shared/state/navigation.store';
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
// jsdom does not implement ResizeObserver; the component only uses it to react to real
// layout changes, which never happen in this headless test.
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver ??= class {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
};
const SUN: StarRecord = { id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 };
const ALPHA_CENTAURI: StarRecord = { id: 1, name: 'Alpha Centauri', x: 1.34, y: 0, z: 0, magnitude: 4.4, spectralType: 'G2V', colorIndex: 0.7 };
const PROXIMA: StarRecord = { id: 2, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 };
const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA];
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
const EARTH: BodyRecord = {
id: 'earth',
systemStarId: SUN.id,
name: 'Earth',
kind: 'planet',
radiusKm: 6371,
orbit: {
semiMajorAxisAu: 1,
eccentricity: 0.0167,
inclinationDeg: 0,
longitudeOfAscendingNodeDeg: 0,
argumentOfPeriapsisDeg: 0,
meanAnomalyAtEpochDeg: 0,
epochJd: 2451545.0
}
};
/** Minimal stand-in for `EngineService` that skips real WebGPU/WebGL initialization entirely,
* while exposing the same tick-registration hook so tests can drive the render loop by hand. */
class FakeEngineService {
private readonly scene = new THREE.Scene();
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
private readonly tickCallbacks = new Set<EngineTickCallback>();
get isInitialized(): boolean {
return true;
}
async init(): Promise<void> {
// no-op: no real renderer/context is created in tests.
}
getScene(): THREE.Scene {
return this.scene;
}
getCamera(): THREE.PerspectiveCamera {
return this.camera;
}
onTick(callback: EngineTickCallback): () => void {
this.tickCallbacks.add(callback);
return () => this.tickCallbacks.delete(callback);
}
start(): void {}
stop(): void {}
dispose(): void {}
resize(): void {}
/** Test helper: simulates one rendered frame by invoking every registered tick callback. */
tick(deltaSeconds: number): void {
for (const callback of this.tickCallbacks) {
callback(deltaSeconds, 0);
}
}
}
class FakeDataLoaderService {
loadStars(): Promise<StarField> {
return Promise.resolve({ stars: STARS, positions: STAR_POSITIONS });
}
loadBodies(): Promise<BodyRecord[]> {
return Promise.resolve([EARTH]);
}
loadExoplanets(): Promise<ExoplanetRecord[]> {
return Promise.resolve([]);
}
}
/** Waits out several macrotask turns so chained promises (bootstrap's awaits) settle. */
async function flushAsync(turns = 8): Promise<void> {
for (let i = 0; i < turns; i++) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
/** Advances the fake render loop (and therefore any in-flight `CameraRigController` tween)
* by repeatedly ticking a small fixed step, flushing microtasks between frames so any
* `onComplete` callback's own side effects (e.g. starting the next leg of the flight) run. */
async function advanceFrames(engine: FakeEngineService, totalSeconds: number, stepSeconds = 0.05): Promise<void> {
let elapsed = 0;
while (elapsed < totalSeconds) {
engine.tick(stepSeconds);
elapsed += stepSeconds;
await flushAsync(1);
}
}
describe('GalaxySystemSceneComponent camera-flight transitions', () => {
let fixture: ComponentFixture<GalaxySystemSceneComponent>;
let engine: FakeEngineService;
let navigationStore: NavigationStore;
beforeEach(async () => {
engine = new FakeEngineService();
TestBed.configureTestingModule({
imports: [GalaxySystemSceneComponent],
providers: [
{ provide: DataLoaderService, useClass: FakeDataLoaderService },
{ provide: Router, useValue: { navigate: vi.fn().mockResolvedValue(true) } }
]
}).overrideComponent(GalaxySystemSceneComponent, {
set: { providers: [{ provide: EngineService, useValue: engine }] }
});
navigationStore = TestBed.inject(NavigationStore);
fixture = TestBed.createComponent(GalaxySystemSceneComponent);
fixture.detectChanges(); // triggers ngAfterViewInit -> bootstrap()
await flushAsync();
});
it('starts in the galaxy view with the system group hidden', () => {
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(navigationStore.viewLevel()).toBe('galaxy');
});
it('flies the camera into a selected star system: hides the galaxy group, shows the system group, and switches to AU-scale near/far planes', async () => {
navigationStore.selectStar(SUN.id);
await flushAsync();
// Approach leg (parsec space) + settle leg (AU space) with margin.
await advanceFrames(engine, 2.5);
const component = fixture.componentInstance as unknown as { galaxyGroup: THREE.Group; systemGroup: THREE.Group };
expect(component.galaxyGroup.visible).toBe(false);
expect(component.systemGroup.visible).toBe(true);
expect(engine.getCamera().near).toBeCloseTo(0.002, 9);
expect(navigationStore.viewLevel()).toBe('system');
});
it('performs the floating-origin recenter: the camera lands close to the AU-space origin, not out at parsec-scale coordinates', async () => {
navigationStore.selectStar(ALPHA_CENTAURI.id);
await flushAsync();
await advanceFrames(engine, 2.5);
// Regardless of how far away (in parsecs) the star was, once we're in system space the
// camera must be within a few thousand AU of the origin -- never still out at the star's
// original parsec-scale distance from the Sun.
const distanceFromOrigin = engine.getCamera().position.length();
expect(distanceFromOrigin).toBeLessThan(1000);
expect(distanceFromOrigin).toBeGreaterThan(0);
});
it('flies back out to the galaxy overview and restores parsec-scale near/far planes when the selection is cleared', async () => {
navigationStore.selectStar(SUN.id);
await flushAsync();
await advanceFrames(engine, 2.5);
expect(navigationStore.viewLevel()).toBe('system');
navigationStore.selectStar(null);
await flushAsync();
await advanceFrames(engine, 2.5);
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);
expect(navigationStore.viewLevel()).toBe('galaxy');
});
it('hopping directly from one system to another exits the first system before entering the second, without settling back in the galaxy view', async () => {
navigationStore.selectStar(SUN.id);
await flushAsync();
await advanceFrames(engine, 2.5);
expect(navigationStore.viewLevel()).toBe('system');
navigationStore.selectStar(ALPHA_CENTAURI.id);
await flushAsync();
await advanceFrames(engine, 3.5);
const component = fixture.componentInstance as unknown as { currentStarId: number | null };
expect(navigationStore.viewLevel()).toBe('system');
expect(component.currentStarId).toBe(ALPHA_CENTAURI.id);
});
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();
// Fire a second selection mid-flight, before the first transition has settled.
await advanceFrames(engine, 0.3);
navigationStore.selectStar(PROXIMA.id);
await flushAsync();
await advanceFrames(engine, 6);
const component = fixture.componentInstance as unknown as { currentStarId: number | null };
expect(component.currentStarId).toBe(PROXIMA.id);
expect(navigationStore.viewLevel()).toBe('system');
});
});
@@ -0,0 +1,447 @@
import { AfterViewInit, Component, effect, ElementRef, OnDestroy, 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 { DataLoaderService } from '../../core/data/data-loader.service';
import { EngineService } from '../../core/engine/engine.service';
import { BodyRecord } from '../../shared/models/body.model';
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 { CameraRigController } from './camera-rig-controller';
import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer';
import { StarLabelOverlay } from './star-label-overlay';
import { SystemOrbitsRenderer } from './system-orbits-renderer';
/** HYG catalog id for the Sun itself — the only star we have a real close-up photo of. */
const SOL_STAR_ID = 0;
const SUN_GLOW_SCALE = 3.2;
/** Stars closer than this to the camera get a name label (always includes the selection). */
const LABEL_MAX_DISTANCE_PC = 20;
/** Caps how many labels are shown at once, to keep the DOM light. */
const LABEL_MAX_COUNT = 15;
/** How often (seconds) the visible label set is recomputed; doesn't need to be per-frame. */
const LABEL_UPDATE_INTERVAL_SECONDS = 0.2;
/** Raycast pick tolerance around each star point, in parsecs. */
const PICK_THRESHOLD_PC = 1.2;
const GALAXY_OVERVIEW_POSITION = new THREE.Vector3(0, 15, 30);
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;
/** How close (pc) the camera dives toward a selected star before the unit-space swap. */
const GALAXY_APPROACH_DISTANCE_PC = 0.05;
const SYSTEM_NEAR_AU = 0.002;
const SYSTEM_FAR_AU = 20000;
const SYSTEM_MIN_DISTANCE_AU = 0.05;
const SYSTEM_MAX_DISTANCE_AU = 5000;
/** Where the camera lands (AU) immediately after swapping into system space, pre-settle. */
const SYSTEM_ENTRY_DISTANCE_AU = 200;
/** How far out (AU) the camera flies before swapping back to galaxy/parsec space. */
const SYSTEM_EXIT_DISTANCE_AU = 400;
const MIN_SYSTEM_FRAMING_DISTANCE_AU = 3;
const MAX_SYSTEM_FRAMING_DISTANCE_AU = 80;
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 STAR_MARKER_RADIUS_AU = 0.2;
/**
* Hosts the shared galaxy + system scene: pan/zoom/rotate camera controls, click-to-select
* picking, proximity-based name labels, and — once a star is selected — a camera-flight
* transition into that star's system (real solar-system bodies for the Sun, cross-referenced
* exoplanets for other stars) with orbit ellipses and planet/moon markers. Owns its own
* `EngineService` instance.
*/
@Component({
selector: 'app-galaxy-system-scene',
providers: [EngineService],
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>
}
</div>
`
})
export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
private readonly labelHostRef = viewChild.required<ElementRef<HTMLDivElement>>('labelHost');
private readonly raycaster = new THREE.Raycaster();
private readonly galaxyGroup = new THREE.Group();
private readonly systemGroup = new THREE.Group();
private readonly starMarkerMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
private readonly starMarkerGeometry = new THREE.SphereGeometry(STAR_MARKER_RADIUS_AU, 24, 16);
private controls?: OrbitControls;
private rig?: CameraRigController;
private starField?: StarFieldRenderer;
private labelOverlay?: StarLabelOverlay;
private stars: readonly StarRecord[] = [];
private starsById = new Map<number, StarRecord>();
private bodies: readonly BodyRecord[] = [];
private exoplanets: readonly ExoplanetRecord[] = [];
private resizeObserver?: ResizeObserver;
private unsubscribeTick?: () => void;
private labelUpdateAccumulator = 0;
private ready = false;
private busy = false;
/** Id of the star whose system is currently shown (or being flown to/from); null = galaxy view. */
private currentStarId: number | null = null;
private systemRenderer?: SystemOrbitsRenderer;
private starMarker?: THREE.Mesh;
private starGlow?: THREE.Sprite;
constructor(
private readonly engine: EngineService,
private readonly dataLoader: DataLoaderService,
private readonly router: Router,
readonly navigationStore: NavigationStore
) {
effect(() => {
const selectedStarId = this.navigationStore.selectedStarId();
if (this.ready) {
this.reconcileSelection(selectedStarId);
}
});
}
ngAfterViewInit(): void {
void this.bootstrap();
}
ngOnDestroy(): void {
this.unsubscribeTick?.();
this.resizeObserver?.disconnect();
this.canvasRef().nativeElement.removeEventListener('click', this.handleClick);
this.controls?.dispose();
this.starField?.dispose();
this.labelOverlay?.dispose();
this.systemRenderer?.dispose();
(this.starMarker?.material as THREE.Material | undefined)?.dispose();
(this.starGlow?.material as THREE.SpriteMaterial | undefined)?.dispose();
this.starMarkerGeometry.dispose();
this.starMarkerMaterial.dispose();
this.engine.dispose();
}
exitSystem(): void {
this.navigationStore.selectStar(null);
}
private async bootstrap(): Promise<void> {
const canvas = this.canvasRef().nativeElement;
try {
await this.engine.init(canvas);
} catch (error) {
console.error('Failed to initialize the 3D engine.', error);
return;
}
const scene = this.engine.getScene();
const camera = this.engine.getCamera();
camera.position.copy(GALAXY_OVERVIEW_POSITION);
camera.near = GALAXY_NEAR_PC;
camera.far = GALAXY_FAR_PC;
camera.updateProjectionMatrix();
this.controls = new OrbitControls(camera, canvas);
this.controls.enableDamping = true;
this.controls.minDistance = GALAXY_MIN_DISTANCE_PC;
this.controls.maxDistance = GALAXY_MAX_DISTANCE_PC;
this.controls.target.copy(GALAXY_OVERVIEW_TARGET);
this.rig = new CameraRigController(camera, this.controls);
scene.add(this.galaxyGroup, this.systemGroup);
this.systemGroup.visible = false;
applyMilkyWaySkybox(scene, MILKY_WAY_SKYBOX_PATH);
const [{ stars, positions }, bodies, exoplanets] = await Promise.all([
this.dataLoader.loadStars(),
this.dataLoader.loadBodies(),
this.dataLoader.loadExoplanets()
]);
this.stars = stars;
this.starsById = new Map(stars.map((star) => [star.id, star]));
this.bodies = bodies;
this.exoplanets = exoplanets;
this.starField = new StarFieldRenderer(stars, positions);
this.galaxyGroup.add(this.starField.object);
this.labelOverlay = new StarLabelOverlay(scene);
this.labelHostRef().nativeElement.appendChild(this.labelOverlay.domElement);
const { width, height } = canvas.getBoundingClientRect();
this.labelOverlay.setSize(width, height);
this.raycaster.params.Points!.threshold = PICK_THRESHOLD_PC;
canvas.addEventListener('click', this.handleClick);
this.observeResize(canvas);
this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(camera, deltaSeconds));
this.engine.start();
this.ready = true;
this.reconcileSelection(this.navigationStore.selectedStarId());
}
private tick(camera: THREE.PerspectiveCamera, deltaSeconds: number): void {
this.rig?.update(deltaSeconds);
this.controls?.update();
if (this.currentStarId === null) {
this.labelUpdateAccumulator += deltaSeconds;
if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) {
this.labelUpdateAccumulator = 0;
this.updateLabels(camera);
}
}
this.systemRenderer?.update(dateToJulianDate());
this.labelOverlay?.render(camera);
}
private updateLabels(camera: THREE.PerspectiveCamera): void {
const selectedId = this.navigationStore.selectedStarId();
const { x: cx, y: cy, z: cz } = camera.position;
const maxDistanceSq = LABEL_MAX_DISTANCE_PC * LABEL_MAX_DISTANCE_PC;
const candidates: Array<{ star: StarRecord; distanceSq: number }> = [];
for (const star of this.stars) {
const dx = star.x - cx;
const dy = star.y - cy;
const dz = star.z - cz;
const distanceSq = dx * dx + dy * dy + dz * dz;
if (distanceSq <= maxDistanceSq || star.id === selectedId) {
candidates.push({ star, distanceSq });
}
}
candidates.sort((a, b) => a.distanceSq - b.distanceSq);
this.labelOverlay?.update(candidates.slice(0, LABEL_MAX_COUNT).map((candidate) => candidate.star));
}
private readonly handleClick = (event: MouseEvent): void => {
if (this.rig?.isAnimating) {
return;
}
const canvas = this.canvasRef().nativeElement;
const camera = this.engine.getCamera();
const rect = canvas.getBoundingClientRect();
const pointerNdc = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
this.raycaster.setFromCamera(pointerNdc, camera);
if (this.currentStarId === null) {
this.handleGalaxyClick();
} else {
this.handleSystemClick();
}
};
private handleGalaxyClick(): void {
if (!this.starField) {
return;
}
const [hit] = this.raycaster.intersectObject(this.starField.object);
const starId = hit?.index !== undefined ? this.starField.starIdAt(hit.index) : undefined;
if (starId !== undefined) {
this.navigationStore.selectStar(starId);
}
}
private handleSystemClick(): void {
if (!this.systemRenderer) {
return;
}
const [hit] = this.raycaster.intersectObjects(this.systemRenderer.pickableObjects);
const member = hit ? this.systemRenderer.memberForObject(hit.object) : undefined;
if (member) {
this.navigationStore.selectBody(member.id);
void this.router.navigate(['/body', member.id]);
}
}
/** Reacts to `NavigationStore.selectedStarId` changes coming from any source (click/search). */
private reconcileSelection(selectedStarId: number | null): void {
if (this.busy || selectedStarId === this.currentStarId) {
return;
}
this.busy = true;
if (selectedStarId === null) {
this.exitToGalaxy(() => this.finishTransition());
} else if (this.currentStarId === null) {
this.enterSystem(selectedStarId, () => this.finishTransition());
} else {
// Star-to-star: exit the current system (short outward hop) then fly into the new one.
this.exitToGalaxy(() => this.enterSystem(selectedStarId, () => this.finishTransition()), true);
}
}
/** Re-checks the store in case the selection changed again while a transition was in flight. */
private finishTransition(): void {
this.busy = false;
this.reconcileSelection(this.navigationStore.selectedStarId());
}
private enterSystem(starId: number, onComplete: () => void): void {
const star = this.starsById.get(starId);
if (!star || !this.rig) {
onComplete();
return;
}
const camera = this.engine.getCamera();
const starPc = new THREE.Vector3(star.x, star.y, star.z);
const direction = camera.position.clone().sub(this.controls!.target).normalize();
if (!Number.isFinite(direction.x) || direction.lengthSq() === 0) {
direction.set(0, 0.3, 1).normalize();
}
const approachPosition = starPc.clone().add(direction.clone().multiplyScalar(GALAXY_APPROACH_DISTANCE_PC));
this.rig.flyTo({ position: approachPosition, target: starPc }, APPROACH_DURATION_SECONDS, () => {
this.swapToSystemSpace(star, direction, onComplete);
});
}
private swapToSystemSpace(star: StarRecord, direction: THREE.Vector3, onComplete: () => void): void {
const camera = this.engine.getCamera();
this.systemRenderer?.dispose();
if (this.starMarker) {
this.systemGroup.remove(this.starMarker);
(this.starMarker.material as THREE.Material).dispose();
}
if (this.starGlow) {
this.systemGroup.remove(this.starGlow);
(this.starGlow.material as THREE.SpriteMaterial).dispose();
this.starGlow = undefined;
}
const systemBodies = this.bodies.filter((body) => body.systemStarId === star.id);
const systemExoplanets = this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id);
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets);
this.systemGroup.add(this.systemRenderer.object);
const starMarkerMaterial = this.starMarkerMaterial.clone();
const starColor = colorIndexToRgb(star.colorIndex);
if (star.id === SOL_STAR_ID) {
// The Sun is the only star we have (and could ever have) a real photograph of; every
// other point in the galaxy view is far too distant to be resolved as a disk.
starMarkerMaterial.map = loadCachedTexture(SUN_TEXTURE_PATH);
starMarkerMaterial.color.set(0xffffff);
this.starGlow = createGlowSprite(0xfff2c0, STAR_MARKER_RADIUS_AU, SUN_GLOW_SCALE);
} else {
starMarkerMaterial.color.copy(starColor);
this.starGlow = createGlowSprite(starColor, STAR_MARKER_RADIUS_AU, SUN_GLOW_SCALE * 0.6);
}
this.starMarker = new THREE.Mesh(this.starMarkerGeometry, starMarkerMaterial);
this.systemGroup.add(this.starMarker, this.starGlow);
this.galaxyGroup.visible = false;
this.systemGroup.visible = true;
camera.near = SYSTEM_NEAR_AU;
camera.far = SYSTEM_FAR_AU;
camera.updateProjectionMatrix();
this.controls!.minDistance = SYSTEM_MIN_DISTANCE_AU;
this.controls!.maxDistance = SYSTEM_MAX_DISTANCE_AU;
this.rig!.setImmediate({ position: direction.clone().multiplyScalar(SYSTEM_ENTRY_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) });
const framingDistance = THREE.MathUtils.clamp(
this.systemRenderer.maxTopLevelSemiMajorAxisAu * 2.4 || MIN_SYSTEM_FRAMING_DISTANCE_AU,
MIN_SYSTEM_FRAMING_DISTANCE_AU,
MAX_SYSTEM_FRAMING_DISTANCE_AU
);
this.rig!.flyTo({ position: direction.clone().multiplyScalar(framingDistance), target: new THREE.Vector3(0, 0, 0) }, SETTLE_DURATION_SECONDS, () => {
this.currentStarId = star.id;
this.navigationStore.setViewLevel('system');
onComplete();
});
}
private exitToGalaxy(onComplete: () => void, isSwitchingSystems = false): void {
if (this.currentStarId === null || !this.rig) {
onComplete();
return;
}
const camera = this.engine.getCamera();
const direction = camera.position.clone().sub(this.controls!.target).normalize();
if (!Number.isFinite(direction.x) || direction.lengthSq() === 0) {
direction.set(0, 0.3, 1).normalize();
}
const exitingStarId = this.currentStarId;
this.rig.flyTo({ position: direction.clone().multiplyScalar(SYSTEM_EXIT_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) }, EXIT_DURATION_SECONDS, () => {
this.swapToGalaxySpace(exitingStarId, direction, isSwitchingSystems, onComplete);
});
}
private swapToGalaxySpace(exitingStarId: number, direction: THREE.Vector3, isSwitchingSystems: boolean, onComplete: () => void): void {
const camera = this.engine.getCamera();
const star = this.starsById.get(exitingStarId);
const starPc = star ? new THREE.Vector3(star.x, star.y, star.z) : GALAXY_OVERVIEW_TARGET.clone();
this.systemGroup.visible = false;
this.galaxyGroup.visible = true;
camera.near = GALAXY_NEAR_PC;
camera.far = GALAXY_FAR_PC;
camera.updateProjectionMatrix();
this.controls!.minDistance = GALAXY_MIN_DISTANCE_PC;
this.controls!.maxDistance = GALAXY_MAX_DISTANCE_PC;
this.rig!.setImmediate({ position: starPc.clone().add(direction.clone().multiplyScalar(GALAXY_APPROACH_DISTANCE_PC)), target: starPc });
if (isSwitchingSystems) {
this.currentStarId = null;
onComplete();
return;
}
this.rig!.flyTo({ position: GALAXY_OVERVIEW_POSITION.clone(), target: GALAXY_OVERVIEW_TARGET.clone() }, RETURN_DURATION_SECONDS, () => {
this.currentStarId = null;
this.navigationStore.setViewLevel('galaxy');
onComplete();
});
}
private observeResize(canvas: HTMLCanvasElement): void {
this.resizeObserver = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
this.engine.resize(width, height);
this.labelOverlay?.setSize(width, height);
});
this.resizeObserver.observe(canvas);
}
}
@@ -0,0 +1,82 @@
import * as THREE from 'three/webgpu';
import { attribute } from 'three/tsl';
import { StarRecord } from '../../shared/models/star.model';
const MIN_POINT_SIZE = 1.5;
const MAX_POINT_SIZE = 6;
const COLD_STAR_COLOR = new THREE.Color(0.65, 0.75, 1.0);
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
const WARM_STAR_COLOR = new THREE.Color(1.0, 0.6, 0.35);
/**
* Crude but effective B-V color-index -> RGB tint: hot/blue stars (low/negative index) skew
* blue-white, cool/red stars (high index) skew orange-red, matching real spectral colors.
*/
export function colorIndexToRgb(colorIndex: number): THREE.Color {
const t = THREE.MathUtils.clamp((colorIndex + 0.4) / 2.4, 0, 1);
const color = new THREE.Color();
return t < 0.5 ? color.lerpColors(COLD_STAR_COLOR, NEUTRAL_STAR_COLOR, t * 2) : color.lerpColors(NEUTRAL_STAR_COLOR, WARM_STAR_COLOR, (t - 0.5) * 2);
}
/** Brighter stars (lower apparent magnitude) render as bigger points. */
function magnitudeToPointSize(magnitude: number): number {
const t = THREE.MathUtils.clamp(1 - (magnitude + 2) / 12, 0, 1);
return MIN_POINT_SIZE + t * (MAX_POINT_SIZE - MIN_POINT_SIZE);
}
/**
* Builds a `THREE.Points` field from the ETL-generated star positions/index, using a TSL
* `PointsNodeMaterial` whose color/size are driven by per-vertex attributes derived from
* each star's spectral color index and magnitude.
*
* Note: per the Three.js WebGPU backend, point primitives are capped at 1px on native
* WebGPU — `sizeNode` only has a visible effect when `WebGPURenderer` has fallen back to
* its WebGL2 backend. Color variation works on both backends.
*/
export class StarFieldRenderer {
readonly object: THREE.Points;
private readonly geometry: THREE.BufferGeometry;
private readonly material: THREE.PointsNodeMaterial;
constructor(private readonly stars: readonly StarRecord[], positions: Float32Array) {
this.geometry = new THREE.BufferGeometry();
this.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const colors = new Float32Array(stars.length * 3);
const sizes = new Float32Array(stars.length);
stars.forEach((star, index) => {
const color = colorIndexToRgb(star.colorIndex);
colors[index * 3] = color.r;
colors[index * 3 + 1] = color.g;
colors[index * 3 + 2] = color.b;
sizes[index] = magnitudeToPointSize(star.magnitude);
});
this.geometry.setAttribute('starColor', new THREE.BufferAttribute(colors, 3));
this.geometry.setAttribute('starSize', new THREE.BufferAttribute(sizes, 1));
this.material = new THREE.PointsNodeMaterial({
colorNode: attribute('starColor', 'vec3'),
sizeNode: attribute('starSize', 'float'),
sizeAttenuation: true,
transparent: true,
depthWrite: false
});
this.object = new THREE.Points(this.geometry, this.material);
}
/** Looks up the HYG star id for a given geometry vertex index (e.g. from a raycast hit). */
starIdAt(vertexIndex: number): number | undefined {
return this.stars[vertexIndex]?.id;
}
dispose(): void {
this.geometry.dispose();
this.material.dispose();
}
}
@@ -0,0 +1,78 @@
import * as THREE from 'three/webgpu';
import { CSS2DObject, CSS2DRenderer } from 'three/addons/renderers/CSS2DRenderer.js';
export interface LabeledPoint {
id: number;
name: string;
x: number;
y: number;
z: number;
}
/**
* Renders DOM-based (CSS2D) name labels anchored to 3D star positions. Labels are added as
* children of the main scene (so `CSS2DRenderer` can project them with the same camera) and
* diffed against the previous frame's set so the DOM is only touched when the visible set
* of stars actually changes, not every frame.
*/
export class StarLabelOverlay {
readonly domElement: HTMLElement;
private readonly cssRenderer = new CSS2DRenderer();
private readonly labelObjects = new Map<number, CSS2DObject>();
constructor(private readonly scene: THREE.Scene) {
this.cssRenderer.domElement.classList.add('star-label-layer');
this.domElement = this.cssRenderer.domElement;
}
setSize(width: number, height: number): void {
this.cssRenderer.setSize(width, height);
}
/** Shows exactly these labels, adding/removing DOM elements only for a changed set. */
update(points: readonly LabeledPoint[]): void {
const idsToShow = new Set(points.map((point) => point.id));
for (const [id, object] of this.labelObjects) {
if (!idsToShow.has(id)) {
this.removeLabel(id, object);
}
}
for (const point of points) {
if (!this.labelObjects.has(point.id)) {
this.addLabel(point);
}
}
}
render(camera: THREE.Camera): void {
this.cssRenderer.render(this.scene, camera);
}
dispose(): void {
for (const [id, object] of this.labelObjects) {
this.removeLabel(id, object);
}
}
private addLabel(point: LabeledPoint): void {
const element = document.createElement('div');
// Tailwind utility classes assigned directly since this element lives outside Angular's
// view encapsulation (see the class comment above) rather than through a component template.
element.className = 'translate-x-1.5 -translate-y-1.5 whitespace-nowrap font-body text-[11px] text-accent [text-shadow:0_0_4px_rgba(0,0,0,0.9)]';
element.textContent = point.name;
const object = new CSS2DObject(element);
object.position.set(point.x, point.y, point.z);
this.scene.add(object);
this.labelObjects.set(point.id, object);
}
private removeLabel(id: number, object: CSS2DObject): void {
this.scene.remove(object);
object.element.remove();
this.labelObjects.delete(id);
}
}
@@ -0,0 +1,236 @@
import * as THREE from 'three/webgpu';
import { gmForParent } from '../../shared/astro/constants';
import { orbitEllipsePoints, propagateOrbit, resolveOrbitalElements } from '../../shared/astro/kepler';
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
export type SystemMemberKind = 'planet' | 'moon' | 'dwarf' | 'exoplanet';
/** A pickable marker for one rendered body/exoplanet, keyed by its own record id. */
export interface SystemMember {
id: string;
kind: SystemMemberKind;
marker: THREE.Object3D;
}
const PLANET_COLOR = new THREE.Color(0.55, 0.75, 1.0);
const DWARF_COLOR = new THREE.Color(0.8, 0.7, 0.55);
const MOON_COLOR = new THREE.Color(0.75, 0.75, 0.75);
const EXOPLANET_COLOR = new THREE.Color(0.85, 0.4, 0.85);
const ORBIT_LINE_OPACITY_BY_KIND: Record<SystemMemberKind, number> = {
planet: 0.5,
dwarf: 0.4,
moon: 0.35,
exoplanet: 0.35
};
const EARTH_RADIUS_KM = 6371;
const MIN_MARKER_RADIUS_AU = 0.012;
const MAX_MARKER_RADIUS_AU = 0.09;
/** Exaggerated (non-physical) marker radius so planets stay visible at AU scale. */
function markerRadiusAu(radiusKm: number | undefined): number {
if (!radiusKm) {
return MIN_MARKER_RADIUS_AU;
}
return THREE.MathUtils.clamp(radiusKm / 18000, MIN_MARKER_RADIUS_AU, MAX_MARKER_RADIUS_AU);
}
function colorForKind(kind: SystemMemberKind): THREE.Color {
switch (kind) {
case 'planet':
return PLANET_COLOR;
case 'dwarf':
return DWARF_COLOR;
case 'moon':
return MOON_COLOR;
case 'exoplanet':
return EXOPLANET_COLOR;
}
}
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind): THREE.Line {
const points = orbitEllipsePoints(elements);
const positions = new Float32Array(points.length * 3);
points.forEach((point, index) => {
positions[index * 3] = point.x;
positions[index * 3 + 1] = point.z; // AU "up" (ecliptic normal) maps to scene Y.
positions[index * 3 + 2] = point.y;
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const material = new THREE.LineBasicMaterial({
color: colorForKind(kind),
transparent: true,
opacity: ORBIT_LINE_OPACITY_BY_KIND[kind]
});
return new THREE.Line(geometry, material);
}
function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined): THREE.Mesh {
const geometry = new THREE.SphereGeometry(markerRadiusAu(radiusKm), 16, 12);
const material = new THREE.MeshBasicMaterial({ color: colorForKind(kind) });
return new THREE.Mesh(geometry, material);
}
interface TrackedTopLevelBody {
id: string;
kind: SystemMemberKind;
elements: OrbitalElements;
gmAu3PerDay2: number;
marker: THREE.Mesh;
/** AU position last computed for this body; moons read their parent's here. */
position: THREE.Vector3;
}
interface TrackedMoon {
id: string;
elements: OrbitalElements;
gmAu3PerDay2: number;
marker: THREE.Mesh;
pivot: THREE.Group;
parentId: string;
}
/**
* Builds and animates the orbit ellipses + planet/moon/exoplanet markers for one star system,
* in AU, with the star itself at the origin. Moons are parented to a pivot group that tracks
* their planet's live position each tick, so their (small, planet-relative) orbit ellipse and
* marker never need to be rebuilt.
*/
export class SystemOrbitsRenderer {
readonly object = new THREE.Group();
readonly members: readonly SystemMember[];
/** Largest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
readonly maxTopLevelSemiMajorAxisAu: number;
private readonly topLevelBodies: TrackedTopLevelBody[] = [];
private readonly moons: TrackedMoon[] = [];
private readonly disposables: Array<{ geometry: THREE.BufferGeometry; material: THREE.Material }> = [];
constructor(bodies: readonly BodyRecord[], exoplanets: readonly ExoplanetRecord[]) {
const members: SystemMember[] = [];
const topLevelBodiesById = new Map<string, BodyRecord>();
for (const body of bodies) {
if (!body.parentBodyId) {
topLevelBodiesById.set(body.id, body);
}
}
for (const body of bodies) {
if (body.parentBodyId) {
continue;
}
// A body reaches here only when it has no parentBodyId, so `kind` is 'planet' or 'dwarf'.
const kind: SystemMemberKind = body.kind;
const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm);
members.push({ id: body.id, kind, marker: tracked.marker });
}
for (const body of bodies) {
if (!body.parentBodyId) {
continue;
}
const parent = topLevelBodiesById.get(body.parentBodyId);
const parentTracked = parent && this.topLevelBodies.find((tracked) => tracked.id === parent.id);
if (!parentTracked) {
continue; // orphaned moon reference; skip rather than crash.
}
const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked);
members.push({ id: body.id, kind: 'moon', marker: moon.marker });
}
for (const exoplanet of exoplanets) {
if (!exoplanet.orbit.semiMajorAxisAu || exoplanet.orbit.eccentricity === undefined) {
continue; // not enough data to place on an orbit.
}
const elements = resolveOrbitalElements({
semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu,
eccentricity: exoplanet.orbit.eccentricity,
inclinationDeg: exoplanet.orbit.inclinationDeg,
longitudeOfAscendingNodeDeg: exoplanet.orbit.longitudeOfAscendingNodeDeg,
argumentOfPeriapsisDeg: exoplanet.orbit.argumentOfPeriapsisDeg,
meanAnomalyAtEpochDeg: exoplanet.orbit.meanAnomalyAtEpochDeg,
epochJd: exoplanet.orbit.epochJd
});
const radiusKm = exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined;
const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, gmForParent(undefined), radiusKm);
members.push({ id: exoplanet.id, kind: 'exoplanet', marker: tracked.marker });
}
this.members = members;
this.maxTopLevelSemiMajorAxisAu = this.topLevelBodies.reduce((max, body) => Math.max(max, body.elements.semiMajorAxisAu), 0);
}
/** Recomputes every marker's position for the given Julian date. Call once per tick. */
update(epochJd: number): void {
for (const body of this.topLevelBodies) {
const { x, y, z } = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd);
body.position.set(x, z, y); // AU "up" maps to scene Y, matching buildOrbitLine.
body.marker.position.copy(body.position);
}
for (const moon of this.moons) {
const parent = this.topLevelBodies.find((body) => body.id === moon.parentId);
if (!parent) {
continue;
}
moon.pivot.position.copy(parent.position);
const { x, y, z } = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd);
moon.marker.position.set(x, z, y);
}
}
/** Looks up which system member a marker object belongs to (e.g. from a raycast hit). */
memberForObject(object: THREE.Object3D): SystemMember | undefined {
return this.members.find((member) => member.marker === object);
}
/** All marker objects, for raycasting. */
get pickableObjects(): THREE.Object3D[] {
return this.members.map((member) => member.marker);
}
dispose(): void {
for (const { geometry, material } of this.disposables) {
geometry.dispose();
material.dispose();
}
}
private addTopLevelBody(id: string, kind: SystemMemberKind, elements: OrbitalElements, gmAu3PerDay2: number, radiusKm: number | undefined): TrackedTopLevelBody {
const orbitLine = buildOrbitLine(elements, kind);
const marker = buildMarker(kind, radiusKm);
this.object.add(orbitLine, marker);
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, position: new THREE.Vector3() };
this.topLevelBodies.push(tracked);
return tracked;
}
private addMoon(id: string, elements: OrbitalElements, gmAu3PerDay2: number, radiusKm: number | undefined, parent: TrackedTopLevelBody): TrackedMoon {
const pivot = new THREE.Group();
const orbitLine = buildOrbitLine(elements, 'moon');
const marker = buildMarker('moon', radiusKm);
pivot.add(orbitLine, marker);
this.object.add(pivot);
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, pivot, parentId: parent.id };
this.moons.push(moon);
return moon;
}
private trackDisposable(geometry: THREE.BufferGeometry, material: THREE.Material): void {
this.disposables.push({ geometry, material });
}
}
View File
+141
View File
@@ -0,0 +1,141 @@
import { Component, computed, signal } from '@angular/core';
import { Router } from '@angular/router';
import { DataLoaderService } from '../../core/data/data-loader.service';
import { NavigationStore } from '../../shared/state/navigation.store';
type SearchResultKind = 'star' | 'body' | 'exoplanet';
interface SearchEntry {
kind: SearchResultKind;
name: string;
subtitle: string;
/** HYG star id, for `kind: 'star'` results. */
starId?: number;
/** `bodies.json`/`exoplanets.json` id, for `kind: 'body' | 'exoplanet'` results. */
bodyId?: string;
}
const MAX_RESULTS = 8;
const MIN_QUERY_LENGTH = 2;
const KIND_LABELS: Record<SearchResultKind, string> = {
star: 'Star',
body: 'Body',
exoplanet: 'Exoplanet'
};
/**
* Name search across stars, solar-system bodies, and exoplanets. Selecting a star result
* jumps straight into that system's view; selecting a body/exoplanet result navigates to its
* detail route — both go through `NavigationStore`/the router so behavior matches an
* in-scene click, per the "consistent state across views" requirement.
*/
@Component({
selector: 'app-search',
template: `
<div class="fixed top-4 left-1/2 z-20 w-[22rem] max-w-[calc(100%-2rem)] -translate-x-1/2 font-body">
<div class="relative">
<svg class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3" />
</svg>
<input
type="text"
placeholder="Search stars, planets, exoplanets…"
[value]="query()"
(input)="onInput($event)"
(keydown.escape)="clear()"
class="w-full rounded-md border border-border bg-panel/85 py-2 pl-9 pr-3 text-sm text-text backdrop-blur-md transition-colors placeholder:text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/50"
/>
</div>
@if (results().length) {
<ul data-testid="search-results" class="mt-1 divide-y divide-border/40 overflow-hidden rounded-md border border-border bg-panel/90 backdrop-blur-md">
@for (result of results(); track result.bodyId ?? result.starId) {
<li>
<button
type="button"
(click)="select(result)"
class="flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors hover:bg-accent/10 focus:bg-accent/10 focus:outline-none"
>
<span class="text-sm text-text">{{ result.name }}</span>
<span class="text-xs tracking-wide text-muted uppercase">{{ kindLabel(result.kind) }} · {{ result.subtitle }}</span>
</button>
</li>
}
</ul>
}
</div>
`
})
export class SearchComponent {
readonly query = signal('');
private readonly index = signal<SearchEntry[]>([]);
readonly results = computed(() => {
const query = this.query().trim().toLowerCase();
if (query.length < MIN_QUERY_LENGTH) {
return [];
}
const matches: SearchEntry[] = [];
for (const entry of this.index()) {
if (entry.name.toLowerCase().includes(query)) {
matches.push(entry);
if (matches.length >= MAX_RESULTS) {
break;
}
}
}
return matches;
});
constructor(
private readonly dataLoader: DataLoaderService,
private readonly navigationStore: NavigationStore,
private readonly router: Router
) {
void this.buildIndex();
}
onInput(event: Event): void {
this.query.set((event.target as HTMLInputElement).value);
}
clear(): void {
this.query.set('');
}
kindLabel(kind: SearchResultKind): string {
return KIND_LABELS[kind];
}
select(result: SearchEntry): void {
this.clear();
if (result.kind === 'star' && result.starId !== undefined) {
this.navigationStore.selectStar(result.starId);
void this.router.navigate(['/']);
} else if (result.bodyId) {
void this.router.navigate(['/body', result.bodyId]);
}
}
private async buildIndex(): Promise<void> {
try {
const [{ stars }, bodies, exoplanets] = await Promise.all([
this.dataLoader.loadStars(),
this.dataLoader.loadBodies(),
this.dataLoader.loadExoplanets()
]);
const entries: SearchEntry[] = [
...stars.map((star): SearchEntry => ({ kind: 'star', name: star.name, subtitle: star.spectralType, starId: star.id })),
...bodies.map((body): SearchEntry => ({ kind: 'body', name: body.name, subtitle: body.kind, bodyId: body.id })),
...exoplanets.map((exoplanet): SearchEntry => ({ kind: 'exoplanet', name: exoplanet.name, subtitle: exoplanet.hostStarName, bodyId: exoplanet.id }))
];
this.index.set(entries);
} catch (error) {
console.error('Failed to build the search index.', error);
}
}
}
+52
View File
@@ -0,0 +1,52 @@
/** Astronomical unit conversion and gravitational constants shared by the astro math modules. */
/** Number of astronomical units in one parsec (IAU exact definition). */
export const AU_PER_PARSEC = 206264.80624709636;
/**
* Reference epoch (Julian date, J2000.0) used when orbital data lacks an explicit epoch —
* e.g. exoplanets from the NASA Exoplanet Archive only report a handful of elements
* (semi-major axis, eccentricity, sometimes argument of periapsis), not a mean-anomaly/epoch
* pair. Defaulting the missing epoch to J2000 still lets the body's real orbital period
* carry it around a plausible (if not phase-accurate) orbit over time.
*/
export const DEFAULT_EPOCH_JD = 2451545.0;
/**
* Heliocentric gravitational parameter (GM of the Sun), in AU^3/day^2 — the square of the
* Gaussian gravitational constant `k = 0.01720209895 rad/day`. Used to derive a body's mean
* motion from its semi-major axis via Kepler's third law.
*/
export const GM_SUN_AU3_PER_DAY2 = 0.01720209895 * 0.01720209895;
/**
* Approximate planet/Sun mass ratios for the major planets that host moons in `bodies.json`.
* Used to derive each planet's gravitational parameter (for propagating its moons) as
* `GM_SUN_AU3_PER_DAY2 * massRatio`. Precise enough for visualization; not JPL-grade.
*/
const PLANET_TO_SUN_MASS_RATIO: Record<string, number> = {
earth: 3.003e-6,
mars: 3.227e-7,
jupiter: 9.545e-4,
saturn: 2.857e-4,
uranus: 4.365e-5,
neptune: 5.151e-5
};
/**
* Gravitational parameter (AU^3/day^2) to use when propagating a body's orbit: the Sun's
* for planets/dwarfs/exoplanets, or the host planet's (derived from its Sun mass ratio) for
* moons. Falls back to the Sun's GM if `parentBodyId` isn't a known planet.
*/
export function gmForParent(parentBodyId: string | undefined): number {
if (!parentBodyId) {
return GM_SUN_AU3_PER_DAY2;
}
const massRatio = PLANET_TO_SUN_MASS_RATIO[parentBodyId];
return massRatio ? GM_SUN_AU3_PER_DAY2 * massRatio : GM_SUN_AU3_PER_DAY2;
}
/** Converts a JS `Date` into a Julian date (days), for driving the Kepler propagator "now". */
export function dateToJulianDate(date: Date = new Date()): number {
return date.getTime() / 86400000 + 2440587.5;
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { distanceBetween, parallaxMasToParsecs, raDecDistanceToXyz, raDegDecDistanceToXyz } from './coordinates';
// Reference values taken directly from the HYG v4.1 database (RA/Dec/dist and its own
// precomputed x/y/z, which uses the same equatorial-Cartesian convention we implement).
describe('raDecDistanceToXyz', () => {
it('matches the HYG reference position for Sirius', () => {
const result = raDecDistanceToXyz(6.752481, -16.716116, 2.6371);
expect(result.x).toBeCloseTo(-0.494323, 3);
expect(result.y).toBeCloseTo(2.476731, 3);
expect(result.z).toBeCloseTo(-0.758485, 3);
});
it('matches the HYG reference position for Proxima Centauri', () => {
const result = raDecDistanceToXyz(14.495985, -62.679485, 1.2959);
expect(result.x).toBeCloseTo(-0.472264, 3);
expect(result.y).toBeCloseTo(-0.361451, 3);
expect(result.z).toBeCloseTo(-1.151219, 3);
});
it('places a star on RA 6h / Dec 0 entirely on the +Y axis', () => {
const result = raDecDistanceToXyz(6, 0, 10);
expect(result.x).toBeCloseTo(0, 9);
expect(result.y).toBeCloseTo(10, 9);
expect(result.z).toBeCloseTo(0, 9);
});
it('places the vernal equinox direction entirely on the +X axis', () => {
const result = raDecDistanceToXyz(0, 0, 10);
expect(result.x).toBeCloseTo(10, 9);
expect(result.y).toBeCloseTo(0, 9);
expect(result.z).toBeCloseTo(0, 9);
});
});
describe('raDegDecDistanceToXyz', () => {
it('is equivalent to raDecDistanceToXyz with RA converted from degrees to hours', () => {
const fromHours = raDecDistanceToXyz(6.752481, -16.716116, 2.6371);
const fromDegrees = raDegDecDistanceToXyz(6.752481 * 15, -16.716116, 2.6371);
expect(fromDegrees.x).toBeCloseTo(fromHours.x, 9);
expect(fromDegrees.y).toBeCloseTo(fromHours.y, 9);
expect(fromDegrees.z).toBeCloseTo(fromHours.z, 9);
});
});
describe('parallaxMasToParsecs', () => {
it('converts a positive parallax to the expected distance', () => {
expect(parallaxMasToParsecs(769.33)).toBeCloseTo(1.3, 2); // Proxima Centauri
});
it('returns Infinity for zero or negative parallax', () => {
expect(parallaxMasToParsecs(0)).toBe(Infinity);
expect(parallaxMasToParsecs(-5)).toBe(Infinity);
});
});
describe('distanceBetween', () => {
it('computes the Euclidean distance between two points', () => {
expect(distanceBetween({ x: 0, y: 0, z: 0 }, { x: 3, y: 4, z: 0 })).toBeCloseTo(5, 9);
});
});
+53
View File
@@ -0,0 +1,53 @@
const DEG_TO_RAD = Math.PI / 180;
const HOURS_TO_DEG = 15;
export interface CartesianCoordinates {
x: number;
y: number;
z: number;
}
/**
* Converts right ascension (hours), declination (degrees) and distance (parsecs) into
* equatorial Cartesian coordinates (parsecs). Matches the HYG database convention:
* +X toward the vernal equinox (epoch 2000), +Z toward the north celestial pole,
* +Y toward RA 6h / Dec 0.
*/
export function raDecDistanceToXyz(raHours: number, decDeg: number, distancePc: number): CartesianCoordinates {
const raRad = raHours * HOURS_TO_DEG * DEG_TO_RAD;
const decRad = decDeg * DEG_TO_RAD;
const cosDec = Math.cos(decRad);
return {
x: distancePc * cosDec * Math.cos(raRad),
y: distancePc * cosDec * Math.sin(raRad),
z: distancePc * Math.sin(decRad)
};
}
/**
* Same conversion as {@link raDecDistanceToXyz}, but for sources (e.g. exoplanet host
* stars, deep-sky catalogs) that report right ascension in degrees rather than hours.
*/
export function raDegDecDistanceToXyz(raDeg: number, decDeg: number, distancePc: number): CartesianCoordinates {
return raDecDistanceToXyz(raDeg / HOURS_TO_DEG, decDeg, distancePc);
}
/**
* Converts a parallax (milliarcseconds) into a distance in parsecs.
* Returns `Infinity` for non-positive parallax (unmeasured/negative parallax).
*/
export function parallaxMasToParsecs(parallaxMas: number): number {
return parallaxMas > 0 ? 1000 / parallaxMas : Infinity;
}
/**
* Euclidean distance (parsecs) between two Cartesian points, e.g. for nearest-neighbour
* star matching during exoplanet host-star cross-referencing.
*/
export function distanceBetween(a: CartesianCoordinates, b: CartesianCoordinates): number {
const dx = a.x - b.x;
const dy = a.y - b.y;
const dz = a.z - b.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { buildStarNameIndex, normalizeStarName, resolveHostStarId } from './host-star-matching';
import { StarRecord } from '../models/star.model';
// A small fixture standing in for a slice of the HYG star index, used to exercise the
// exoplanet host-star cross-referencing logic without hitting any real API.
const FIXTURE_STARS: StarRecord[] = [
{ id: 1, name: 'Proxima Centauri', x: -0.472264, y: -0.361451, z: -1.151219, magnitude: 11.01, spectralType: 'M5Ve', colorIndex: 1.807 },
{ id: 2, name: 'Sirius', x: -0.494323, y: 2.476731, z: -0.758485, magnitude: -1.44, spectralType: 'A0m...', colorIndex: 0.009 },
{ id: 3, name: 'GJ 3512', x: 3.0, y: 4.0, z: 5.0, magnitude: 11.0, spectralType: 'M5.5', colorIndex: 1.6 }
];
describe('normalizeStarName', () => {
it('lowercases and strips non-alphanumeric characters', () => {
expect(normalizeStarName('GJ 3512')).toBe('gj3512');
expect(normalizeStarName('Proxima Centauri')).toBe('proximacentauri');
});
});
describe('resolveHostStarId', () => {
it('matches by exact (normalized) host star name', () => {
const id = resolveHostStarId({ hostname: 'Proxima Centauri', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS, 0.5);
expect(id).toBe(1);
});
it('matches by name regardless of case/spacing differences', () => {
const id = resolveHostStarId({ hostname: 'gj3512', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS, 0.5);
expect(id).toBe(3);
});
it('falls back to nearest-neighbour position matching when the name is unknown', () => {
// Slightly off from Sirius's exact position, within tolerance.
const id = resolveHostStarId({ hostname: 'Sirius A', raDeg: 101.29, decDeg: -16.72, distancePc: 2.64 }, FIXTURE_STARS, 0.5);
expect(id).toBe(2);
});
it('returns null when no name match and no star is within tolerance', () => {
const id = resolveHostStarId({ hostname: 'Unknown Star XYZ', raDeg: 0, decDeg: 0, distancePc: 100 }, FIXTURE_STARS, 0.5);
expect(id).toBeNull();
});
it('returns null when there is no name match and no position is available', () => {
const id = resolveHostStarId({ hostname: 'Unknown Star XYZ', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS, 0.5);
expect(id).toBeNull();
});
it('picks the closest star when more than one falls within tolerance', () => {
const stars: StarRecord[] = [
{ id: 10, name: 'Near', x: 0, y: 0, z: 0, magnitude: 5, spectralType: 'G', colorIndex: 0.5 },
{ id: 11, name: 'Far', x: 0.4, y: 0, z: 0, magnitude: 5, spectralType: 'G', colorIndex: 0.5 }
];
const nameIndex = buildStarNameIndex(stars);
const id = resolveHostStarId({ hostname: 'Unmatched', raDeg: 0, decDeg: 0, distancePc: 0.2 }, stars, 0.5, nameIndex);
expect(id).toBe(10);
});
});
@@ -0,0 +1,59 @@
import { CartesianCoordinates, distanceBetween, raDegDecDistanceToXyz } from './coordinates';
import { StarRecord } from '../models/star.model';
/** Normalizes a star name for comparison: lowercase, alphanumeric characters only. */
export function normalizeStarName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]/g, '');
}
export interface HostStarQuery {
hostname: string;
raDeg: number;
decDeg: number;
distancePc: number;
}
/** Builds a lookup of normalized star name -> star, for fast repeated name matching. */
export function buildStarNameIndex(stars: readonly StarRecord[]): Map<string, StarRecord> {
return new Map(stars.map((star) => [normalizeStarName(star.name), star]));
}
/**
* Cross-references an exoplanet host star to the HYG star index: first by (normalized)
* name, then by nearest-neighbour position matching within `toleranceInPc`. Returns `null`
* when neither approach finds a confident match, rather than guessing.
*
* `nameIndex` should be built once (via {@link buildStarNameIndex}) and reused across calls
* when resolving many queries against the same star list.
*/
export function resolveHostStarId(
query: HostStarQuery,
stars: readonly StarRecord[],
toleranceInPc: number,
nameIndex: Map<string, StarRecord> = buildStarNameIndex(stars)
): number | null {
const byName = nameIndex.get(normalizeStarName(query.hostname));
if (byName) {
return byName.id;
}
if (![query.raDeg, query.decDeg, query.distancePc].every(Number.isFinite)) {
return null;
}
const hostPosition = raDegDecDistanceToXyz(query.raDeg, query.decDeg, query.distancePc);
return findNearestStarWithin(hostPosition, stars, toleranceInPc);
}
function findNearestStarWithin(position: CartesianCoordinates, stars: readonly StarRecord[], toleranceInPc: number): number | null {
let closest: { id: number; distance: number } | null = null;
for (const star of stars) {
const distance = distanceBetween(position, star);
if (distance <= toleranceInPc && (!closest || distance < closest.distance)) {
closest = { id: star.id, distance };
}
}
return closest ? closest.id : null;
}
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest';
import { GM_SUN_AU3_PER_DAY2, DEFAULT_EPOCH_JD } from './constants';
import {
meanMotionRadPerDay,
orbitEllipsePoints,
orbitalPeriodDays,
positionAtTrueAnomaly,
propagateOrbit,
resolveOrbitalElements,
solveEccentricAnomaly,
trueAnomalyFromEccentricAnomaly
} from './kepler';
// Earth's actual orbital elements (osculating, ~J2000), used as a real-world reference case.
const EARTH_ELEMENTS = {
semiMajorAxisAu: 1.00000011,
eccentricity: 0.01671022,
inclinationDeg: 0.00005,
longitudeOfAscendingNodeDeg: -11.26064,
argumentOfPeriapsisDeg: 102.94719,
meanAnomalyAtEpochDeg: 100.46435,
epochJd: DEFAULT_EPOCH_JD
};
describe('solveEccentricAnomaly', () => {
it('satisfies Keplers equation for a range of eccentricities', () => {
for (const eccentricity of [0, 0.0167, 0.3, 0.6, 0.9]) {
for (const meanAnomalyRad of [0, 0.5, 1.5, 3.0, 5.5]) {
const e = solveEccentricAnomaly(meanAnomalyRad, eccentricity);
const residual = e - eccentricity * Math.sin(e) - meanAnomalyRad;
// residual is computed against the (possibly un-normalized) input, but the solver
// normalizes internally, so compare against the normalized mean anomaly instead.
const normalizedMeanAnomaly = ((meanAnomalyRad % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
expect(e - eccentricity * Math.sin(e)).toBeCloseTo(normalizedMeanAnomaly, 6);
expect(Number.isFinite(residual)).toBe(true);
}
}
});
});
describe('trueAnomalyFromEccentricAnomaly', () => {
it('returns 0 at periapsis and pi at apoapsis', () => {
expect(trueAnomalyFromEccentricAnomaly(0, 0.3)).toBeCloseTo(0, 9);
expect(trueAnomalyFromEccentricAnomaly(Math.PI, 0.3)).toBeCloseTo(Math.PI, 9);
});
it('matches the eccentric anomaly exactly for a circular orbit', () => {
expect(trueAnomalyFromEccentricAnomaly(1.234, 0)).toBeCloseTo(1.234, 9);
});
});
describe('positionAtTrueAnomaly', () => {
it('places a circular, unrotated orbit at radius = semiMajorAxisAu for every true anomaly', () => {
const circular = resolveOrbitalElements({ semiMajorAxisAu: 2.5, eccentricity: 0 });
for (const trueAnomalyRad of [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2]) {
const { x, y, z } = positionAtTrueAnomaly(circular, trueAnomalyRad);
expect(Math.sqrt(x * x + y * y + z * z)).toBeCloseTo(2.5, 9);
}
});
it('reaches periapsis distance a*(1-e) and apoapsis distance a*(1+e)', () => {
const elements = resolveOrbitalElements({ semiMajorAxisAu: 10, eccentricity: 0.2 });
const periapsis = positionAtTrueAnomaly(elements, 0);
const apoapsis = positionAtTrueAnomaly(elements, Math.PI);
expect(Math.hypot(periapsis.x, periapsis.y, periapsis.z)).toBeCloseTo(8, 9);
expect(Math.hypot(apoapsis.x, apoapsis.y, apoapsis.z)).toBeCloseTo(12, 9);
});
it('tilts a 90-degree-inclined orbit entirely onto the z axis at true anomaly 90 degrees', () => {
const elements = resolveOrbitalElements({ semiMajorAxisAu: 1, eccentricity: 0, inclinationDeg: 90 });
const { x, y, z } = positionAtTrueAnomaly(elements, Math.PI / 2);
expect(x).toBeCloseTo(0, 9);
expect(y).toBeCloseTo(0, 9);
expect(z).toBeCloseTo(1, 9);
});
});
describe('meanMotionRadPerDay / orbitalPeriodDays', () => {
it('reproduces Earths ~365.25-day year from its semi-major axis', () => {
const period = orbitalPeriodDays(EARTH_ELEMENTS.semiMajorAxisAu, GM_SUN_AU3_PER_DAY2);
expect(period).toBeCloseTo(365.25, 0);
});
it('is the inverse of orbitalPeriodDays', () => {
const n = meanMotionRadPerDay(1, GM_SUN_AU3_PER_DAY2);
const period = orbitalPeriodDays(1, GM_SUN_AU3_PER_DAY2);
expect(n * period).toBeCloseTo(2 * Math.PI, 9);
});
});
describe('propagateOrbit', () => {
it('reduces to the instantaneous position at the elements own epoch', () => {
const eccentricAnomalyRad = solveEccentricAnomaly((EARTH_ELEMENTS.meanAnomalyAtEpochDeg * Math.PI) / 180, EARTH_ELEMENTS.eccentricity);
const trueAnomalyRad = trueAnomalyFromEccentricAnomaly(eccentricAnomalyRad, EARTH_ELEMENTS.eccentricity);
const expected = positionAtTrueAnomaly(EARTH_ELEMENTS, trueAnomalyRad);
const actual = propagateOrbit(EARTH_ELEMENTS, GM_SUN_AU3_PER_DAY2, EARTH_ELEMENTS.epochJd);
expect(actual.x).toBeCloseTo(expected.x, 9);
expect(actual.y).toBeCloseTo(expected.y, 9);
expect(actual.z).toBeCloseTo(expected.z, 9);
});
it('stays within the periapsis/apoapsis distance bounds after propagating forward a year', () => {
const period = orbitalPeriodDays(EARTH_ELEMENTS.semiMajorAxisAu, GM_SUN_AU3_PER_DAY2);
const { x, y, z } = propagateOrbit(EARTH_ELEMENTS, GM_SUN_AU3_PER_DAY2, EARTH_ELEMENTS.epochJd + period * 0.37);
const distance = Math.hypot(x, y, z);
const { semiMajorAxisAu: a, eccentricity: e } = EARTH_ELEMENTS;
expect(distance).toBeGreaterThanOrEqual(a * (1 - e) - 1e-6);
expect(distance).toBeLessThanOrEqual(a * (1 + e) + 1e-6);
});
it('returns to (very nearly) the same position after exactly one full orbital period', () => {
const period = orbitalPeriodDays(EARTH_ELEMENTS.semiMajorAxisAu, GM_SUN_AU3_PER_DAY2);
const start = propagateOrbit(EARTH_ELEMENTS, GM_SUN_AU3_PER_DAY2, EARTH_ELEMENTS.epochJd + 12.3);
const afterOneOrbit = propagateOrbit(EARTH_ELEMENTS, GM_SUN_AU3_PER_DAY2, EARTH_ELEMENTS.epochJd + 12.3 + period);
expect(afterOneOrbit.x).toBeCloseTo(start.x, 6);
expect(afterOneOrbit.y).toBeCloseTo(start.y, 6);
expect(afterOneOrbit.z).toBeCloseTo(start.z, 6);
});
});
describe('orbitEllipsePoints', () => {
it('samples a closed loop whose distances stay within the periapsis/apoapsis bounds', () => {
const elements = resolveOrbitalElements({ semiMajorAxisAu: 5, eccentricity: 0.4 });
const points = orbitEllipsePoints(elements, 64);
expect(points).toHaveLength(65);
for (const { x, y, z } of points) {
const distance = Math.hypot(x, y, z);
expect(distance).toBeGreaterThanOrEqual(5 * (1 - 0.4) - 1e-9);
expect(distance).toBeLessThanOrEqual(5 * (1 + 0.4) + 1e-9);
}
// The first and last sampled points (true anomaly 0 and 2*pi) should coincide.
expect(points[0].x).toBeCloseTo(points[64].x, 9);
expect(points[0].y).toBeCloseTo(points[64].y, 9);
expect(points[0].z).toBeCloseTo(points[64].z, 9);
});
});
describe('resolveOrbitalElements', () => {
it('defaults missing angles to 0 and the missing epoch to J2000', () => {
const resolved = resolveOrbitalElements({ semiMajorAxisAu: 1.5, eccentricity: 0.1 });
expect(resolved.inclinationDeg).toBe(0);
expect(resolved.longitudeOfAscendingNodeDeg).toBe(0);
expect(resolved.argumentOfPeriapsisDeg).toBe(0);
expect(resolved.meanAnomalyAtEpochDeg).toBe(0);
expect(resolved.epochJd).toBe(DEFAULT_EPOCH_JD);
});
it('preserves explicitly provided fields', () => {
const resolved = resolveOrbitalElements({ semiMajorAxisAu: 1.5, eccentricity: 0.1, argumentOfPeriapsisDeg: 50 });
expect(resolved.argumentOfPeriapsisDeg).toBe(50);
});
});
+136
View File
@@ -0,0 +1,136 @@
import { CartesianCoordinates } from './coordinates';
import { DEFAULT_EPOCH_JD } from './constants';
import { OrbitalElements } from '../models/body.model';
const DEG_TO_RAD = Math.PI / 180;
const TWO_PI = Math.PI * 2;
/**
* Fills in the elements the Kepler propagator needs but that some sources (e.g. exoplanets,
* see `ExoplanetRecord.orbit: Partial<OrbitalElements>`) don't report: inclination, longitude
* of ascending node, mean anomaly at epoch, and the epoch itself. Missing angles default to
* zero (a face-on, unrotated ellipse) and the missing epoch defaults to J2000 — enough to draw
* a plausible, period-correct orbit even without full data.
*/
export function resolveOrbitalElements(partial: Partial<OrbitalElements> & Pick<OrbitalElements, 'semiMajorAxisAu' | 'eccentricity'>): OrbitalElements {
return {
semiMajorAxisAu: partial.semiMajorAxisAu,
eccentricity: partial.eccentricity,
inclinationDeg: partial.inclinationDeg ?? 0,
longitudeOfAscendingNodeDeg: partial.longitudeOfAscendingNodeDeg ?? 0,
argumentOfPeriapsisDeg: partial.argumentOfPeriapsisDeg ?? 0,
meanAnomalyAtEpochDeg: partial.meanAnomalyAtEpochDeg ?? 0,
epochJd: partial.epochJd ?? DEFAULT_EPOCH_JD
};
}
/** Mean motion (rad/day) of a body via Kepler's third law: n = sqrt(GM / a^3). */
export function meanMotionRadPerDay(semiMajorAxisAu: number, gmAu3PerDay2: number): number {
return Math.sqrt(gmAu3PerDay2 / (semiMajorAxisAu * semiMajorAxisAu * semiMajorAxisAu));
}
/** Orbital period (days) of a body via Kepler's third law: T = 2*pi / n. */
export function orbitalPeriodDays(semiMajorAxisAu: number, gmAu3PerDay2: number): number {
return TWO_PI / meanMotionRadPerDay(semiMajorAxisAu, gmAu3PerDay2);
}
/** Normalizes an angle (radians) into [0, 2*pi). */
function normalizeAngle(angleRad: number): number {
const wrapped = angleRad % TWO_PI;
return wrapped < 0 ? wrapped + TWO_PI : wrapped;
}
/**
* Solves Kepler's equation `M = E - e*sin(E)` for the eccentric anomaly `E` (radians) via
* Newton-Raphson iteration.
*/
export function solveEccentricAnomaly(meanAnomalyRad: number, eccentricity: number, tolerance = 1e-8, maxIterations = 30): number {
const m = normalizeAngle(meanAnomalyRad);
let e = eccentricity < 0.8 ? m : Math.PI;
for (let i = 0; i < maxIterations; i++) {
const delta = (e - eccentricity * Math.sin(e) - m) / (1 - eccentricity * Math.cos(e));
e -= delta;
if (Math.abs(delta) < tolerance) {
break;
}
}
return e;
}
/** Converts an eccentric anomaly (radians) into the true anomaly (radians). */
export function trueAnomalyFromEccentricAnomaly(eccentricAnomalyRad: number, eccentricity: number): number {
const cosE = Math.cos(eccentricAnomalyRad);
const sinE = Math.sin(eccentricAnomalyRad);
return Math.atan2(Math.sqrt(1 - eccentricity * eccentricity) * sinE, cosE - eccentricity);
}
/**
* Places a point at the given true anomaly (radians) along the orbit described by
* `elements`, in AU, relative to the central body (the Sun for planets/dwarfs, the host
* planet for moons — see `BodyRecord.parentBodyId`). Standard perifocal-to-reference-frame
* rotation: argument of periapsis, then inclination, then longitude of ascending node.
*/
export function positionAtTrueAnomaly(elements: OrbitalElements, trueAnomalyRad: number): CartesianCoordinates {
const { semiMajorAxisAu: a, eccentricity: e } = elements;
const semiLatusRectum = a * (1 - e * e);
const radius = semiLatusRectum / (1 + e * Math.cos(trueAnomalyRad));
// Position in the perifocal (orbital-plane) frame: +x toward periapsis.
const xPerifocal = radius * Math.cos(trueAnomalyRad);
const yPerifocal = radius * Math.sin(trueAnomalyRad);
const omega = elements.argumentOfPeriapsisDeg * DEG_TO_RAD; // argument of periapsis
const inclination = elements.inclinationDeg * DEG_TO_RAD;
const raan = elements.longitudeOfAscendingNodeDeg * DEG_TO_RAD; // right ascension of ascending node
const cosOmega = Math.cos(omega);
const sinOmega = Math.sin(omega);
const cosInclination = Math.cos(inclination);
const sinInclination = Math.sin(inclination);
const cosRaan = Math.cos(raan);
const sinRaan = Math.sin(raan);
// Rotate by argument of periapsis within the orbital plane first.
const xOrbitPlane = xPerifocal * cosOmega - yPerifocal * sinOmega;
const yOrbitPlane = xPerifocal * sinOmega + yPerifocal * cosOmega;
// Tilt by inclination, then rotate by the longitude of the ascending node.
const xTilted = xOrbitPlane;
const yTilted = yOrbitPlane * cosInclination;
const zTilted = yOrbitPlane * sinInclination;
return {
x: xTilted * cosRaan - yTilted * sinRaan,
y: xTilted * sinRaan + yTilted * cosRaan,
z: zTilted
};
}
/**
* Propagates `elements` to Julian date `epochJdEval`, returning the body's position (AU)
* relative to its central body. This is the app's "current epoch" evaluation used for live
* (and future time-scrubbable) positions, as opposed to {@link orbitEllipsePoints} which
* samples the fixed orbit shape independent of time.
*/
export function propagateOrbit(elements: OrbitalElements, gmAu3PerDay2: number, epochJdEval: number): CartesianCoordinates {
const meanMotion = meanMotionRadPerDay(elements.semiMajorAxisAu, gmAu3PerDay2);
const meanAnomalyRad = elements.meanAnomalyAtEpochDeg * DEG_TO_RAD + meanMotion * (epochJdEval - elements.epochJd);
const eccentricAnomalyRad = solveEccentricAnomaly(meanAnomalyRad, elements.eccentricity);
const trueAnomalyRad = trueAnomalyFromEccentricAnomaly(eccentricAnomalyRad, elements.eccentricity);
return positionAtTrueAnomaly(elements, trueAnomalyRad);
}
/**
* Samples `segments` points around the fixed shape of the orbit (AU, relative to the central
* body), for drawing the orbit ellipse. Independent of epoch/time — unlike {@link propagateOrbit}.
*/
export function orbitEllipsePoints(elements: OrbitalElements, segments = 128): CartesianCoordinates[] {
const points: CartesianCoordinates[] = [];
for (let i = 0; i <= segments; i++) {
const trueAnomalyRad = (i / segments) * TWO_PI;
points.push(positionAtTrueAnomaly(elements, trueAnomalyRad));
}
return points;
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Osculating Keplerian orbital elements at a reference epoch. Positions are derived
* client-side by propagating these elements forward/backward from `epochJd` (see
* `shared/astro/kepler.ts`), rather than fetching per-frame positions.
*/
export interface OrbitalElements {
semiMajorAxisAu: number;
eccentricity: number;
inclinationDeg: number;
longitudeOfAscendingNodeDeg: number;
argumentOfPeriapsisDeg: number;
meanAnomalyAtEpochDeg: number;
epochJd: number;
}
/**
* A solar-system planet, moon, or dwarf planet, sourced from JPL Horizons/SSD orbital
* elements. `systemStarId` links back to the HYG star index (the Sun, see `SUN_STAR_ID`).
*/
export interface BodyRecord {
id: string;
systemStarId: number;
name: string;
kind: 'planet' | 'moon' | 'dwarf';
radiusKm: number;
orbit: OrbitalElements;
/**
* For `kind: 'moon'`, the `id` of the planet it orbits — its `orbit` is expressed
* relative to that planet, not heliocentrically. Undefined for planets/dwarfs.
*/
parentBodyId?: string;
}
+16
View File
@@ -0,0 +1,16 @@
import { OrbitalElements } from './body.model';
/**
* A confirmed exoplanet from the NASA Exoplanet Archive (`Planetary Systems` TAP table),
* cross-referenced to its host star in the HYG index where possible.
*/
export interface ExoplanetRecord {
id: string;
hostStarId: number | null; // null if the host star could not be cross-referenced to HYG
hostStarName: string;
name: string;
radiusEarth?: number;
massEarth?: number;
discoveryYear?: number;
orbit: Partial<OrbitalElements>;
}
+19
View File
@@ -0,0 +1,19 @@
/**
* A single star from the HYG (Hipparcos/Yale/Gliese) catalog, positioned relative to the
* Sun in the galaxy-scale coordinate system (parsecs). The same positions are also packed
* into a compact binary buffer (`stars.bin`, in index order) for fast bulk rendering; this
* record format (`stars-index.json`) is used for search, labels, and lookups by id/name.
*/
export interface StarRecord {
id: number;
name: string;
x: number;
y: number;
z: number;
magnitude: number;
spectralType: string;
colorIndex: number;
}
/** HYG id used for the Sun itself, so solar-system bodies can reference their host star. */
export const SUN_STAR_ID = 0;
+78
View File
@@ -0,0 +1,78 @@
import * as THREE from 'three/webgpu';
/**
* Applies a real Milky Way panorama (see `texture-catalog.ts`/`README.md`) as the scene's
* background, mapped equirectangularly so it wraps the camera like a real sky rather than a
* flat image. Loading is non-blocking: the scene renders immediately with its previous
* background and swaps in the photo once it decodes.
*/
export function applyMilkyWaySkybox(scene: THREE.Scene, path: string): void {
new THREE.TextureLoader().load(
path,
(texture) => {
texture.mapping = THREE.EquirectangularReflectionMapping;
texture.colorSpace = THREE.SRGBColorSpace;
scene.background = texture;
},
undefined,
(error) => console.error(`Failed to load the skybox texture "${path}".`, error)
);
}
const glowSpriteCache = new Map<string, THREE.Texture>();
/**
* A soft radial-gradient canvas texture, cached per color, used to fake atmosphere/corona glow.
* Returns `undefined` if 2D canvas rendering isn't available (e.g. under a test/jsdom
* environment with no canvas backend) so callers can fall back to a flat-color sprite instead.
*/
function glowSpriteTexture(color: THREE.ColorRepresentation): THREE.Texture | undefined {
const key = new THREE.Color(color).getHexString();
const cached = glowSpriteCache.get(key);
if (cached) {
return cached;
}
const size = 128;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (!context) {
return undefined;
}
const rgb = new THREE.Color(color);
const [r, g, b] = [Math.round(rgb.r * 255), Math.round(rgb.g * 255), Math.round(rgb.b * 255)];
const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.85)`);
gradient.addColorStop(0.4, `rgba(${r}, ${g}, ${b}, 0.35)`);
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
context.fillStyle = gradient;
context.fillRect(0, 0, size, size);
const texture = new THREE.CanvasTexture(canvas);
glowSpriteCache.set(key, texture);
return texture;
}
/**
* Builds a soft additive-blended glow halo (used for planetary atmospheres and the Sun's
* corona) sized relative to the given object radius. Cheap billboard-sprite approximation
* rather than a view-angle-correct Fresnel shader, chosen to stay within built-in material
* types the WebGPU backend renders natively (see plan risk on TSL/shader maturity). Falls back
* to a flat-colored (gradient-less) sprite if canvas rendering is unavailable.
*/
export function createGlowSprite(color: THREE.ColorRepresentation, radius: number, scale: number): THREE.Sprite {
const material = new THREE.SpriteMaterial({
map: glowSpriteTexture(color),
color: color,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending
});
const sprite = new THREE.Sprite(material);
sprite.scale.setScalar(radius * scale);
return sprite;
}
+140
View File
@@ -0,0 +1,140 @@
import * as THREE from 'three/webgpu';
/**
* Real NASA/ESA/USGS photography baked into `src/assets/textures/bodies/` at build time,
* keyed by the same ids used in `bodies.json`. Bodies without an entry here (most exoplanets,
* a few moons whose photo wasn't sourced this round, and any future body) fall back to
* `proceduralBodyTexture()` below rather than a flat color.
*
* Provenance (all public domain NASA/JPL or CC BY 4.0 Solar System Scope, via Wikimedia
* Commons — see each file's Commons page for the original credit line):
* mercury/venus/earth/mars/saturn/uranus/neptune/moon/sun/saturn-ring/skybox — Solar System
* Scope texture pack (CC BY 4.0); jupiter — Solar System Scope 8k pack (CC BY 4.0); pluto —
* NASA/JHUAPL/SwRI New Horizons true-color mosaic; deimos — NASA/JPL/University of Arizona
* MRO HiRISE; io — NASA/JPL Galileo highest-resolution true-color mosaic; titan — NASA/JPL
* Cassini true-color view.
*/
const BODY_TEXTURE_PATHS: Record<string, string> = {
mercury: 'assets/textures/bodies/mercury.jpg',
venus: 'assets/textures/bodies/venus.jpg',
earth: 'assets/textures/bodies/earth.jpg',
mars: 'assets/textures/bodies/mars.jpg',
jupiter: 'assets/textures/bodies/jupiter.jpg',
saturn: 'assets/textures/bodies/saturn.jpg',
uranus: 'assets/textures/bodies/uranus.jpg',
neptune: 'assets/textures/bodies/neptune.jpg',
pluto: 'assets/textures/bodies/pluto.jpg',
moon: 'assets/textures/bodies/moon.jpg',
deimos: 'assets/textures/bodies/deimos.jpg',
io: 'assets/textures/bodies/io.jpg',
titan: 'assets/textures/bodies/titan.jpg'
};
/** The Sun isn't a `BodyRecord` (it's the system's star marker), so it's looked up separately. */
export const SUN_TEXTURE_PATH = 'assets/textures/bodies/sun.jpg';
export const SATURN_RING_TEXTURE_PATH = 'assets/textures/bodies/saturn_ring.png';
export const MILKY_WAY_SKYBOX_PATH = 'assets/textures/skybox/milkyway.jpg';
/** True for the handful of bodies that have real photographic atmospheres worth glowing. */
const ATMOSPHERE_BY_ID: Record<string, THREE.ColorRepresentation> = {
venus: 0xf3dfa6,
earth: 0x7fb8ff,
mars: 0xd9a066,
jupiter: 0xe8d3ad,
saturn: 0xe0d2a8,
uranus: 0x9fe8e8,
neptune: 0x5b7fff,
titan: 0xf0b25c
};
export function bodyTexturePath(id: string): string | undefined {
return BODY_TEXTURE_PATHS[id];
}
export function atmosphereColorFor(id: string): THREE.ColorRepresentation | undefined {
return ATMOSPHERE_BY_ID[id];
}
const textureLoader = new THREE.TextureLoader();
const loadedTextures = new Map<string, THREE.Texture>();
/**
* Loads (and caches) a texture by asset path, applying `colorSpace` so JPEG/PNG source
* photography matches Three.js's expected sRGB working space. Non-blocking: the texture is
* returned immediately and updates in place once the image data arrives (or errors, which is
* logged rather than thrown so a slow/unavailable network never breaks the scene).
*/
export function loadCachedTexture(path: string): THREE.Texture {
const cached = loadedTextures.get(path);
if (cached) {
return cached;
}
const texture = textureLoader.load(
path,
undefined,
undefined,
(error) => console.error(`Failed to load texture "${path}".`, error)
);
texture.colorSpace = THREE.SRGBColorSpace;
loadedTextures.set(path, texture);
return texture;
}
const proceduralTextureCache = new Map<string, THREE.CanvasTexture>();
/**
* Generates a simple procedural surface for bodies with no real photograph available — mainly
* exoplanets, whose actual surfaces have never been directly imaged. This is an honest artistic
* stand-in (mottled bands tinted by the body's classification color), not a fabricated "real"
* texture, and is cached per color so repeated exoplanets of the same kind share one canvas.
* Returns `undefined` if 2D canvas rendering isn't available (e.g. under a test/jsdom
* environment with no canvas backend); callers should fall back to a flat material color.
*/
export function proceduralBodyTexture(baseColor: THREE.ColorRepresentation): THREE.CanvasTexture | undefined {
const key = new THREE.Color(baseColor).getHexString();
const cached = proceduralTextureCache.get(key);
if (cached) {
return cached;
}
const size = 256;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (!context) {
return undefined;
}
const base = new THREE.Color(baseColor);
const light = base.clone().offsetHSL(0, -0.15, 0.14);
const dark = base.clone().offsetHSL(0, 0.05, -0.16);
context.fillStyle = `#${base.getHexString()}`;
context.fillRect(0, 0, size, size);
// A handful of horizontal-ish noisy bands, reminiscent of banded gas giants / mottled rock,
// without claiming to depict any specific real surface feature.
let seed = key.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) || 1;
const random = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
const bandCount = 10;
for (let i = 0; i < bandCount; i++) {
const y = (i / bandCount) * size + random() * (size / bandCount) * 0.4;
const height = size / bandCount * (0.5 + random() * 0.6);
context.fillStyle = `#${(random() > 0.5 ? light : dark).getHexString()}`;
context.globalAlpha = 0.35 + random() * 0.25;
context.fillRect(0, y, size, height);
}
context.globalAlpha = 1;
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = THREE.RepeatWrapping;
proceduralTextureCache.set(key, texture);
return texture;
}
View File
@@ -0,0 +1,37 @@
import { TestBed } from '@angular/core/testing';
import { describe, beforeEach, expect, it } from 'vitest';
import { NavigationStore } from './navigation.store';
describe('NavigationStore', () => {
let store: NavigationStore;
beforeEach(() => {
TestBed.configureTestingModule({});
store = TestBed.inject(NavigationStore);
});
it('starts at the galaxy view with nothing selected', () => {
expect(store.viewLevel()).toBe('galaxy');
expect(store.selectedStarId()).toBeNull();
expect(store.selectedBodyId()).toBeNull();
});
it('selectStar updates selectedStarId', () => {
store.selectStar(42);
expect(store.selectedStarId()).toBe(42);
});
it('selectBody updates selectedBodyId', () => {
store.selectBody('mars');
expect(store.selectedBodyId()).toBe('mars');
});
it('setViewLevel switches between galaxy and system', () => {
store.setViewLevel('system');
expect(store.viewLevel()).toBe('system');
});
});
+27
View File
@@ -0,0 +1,27 @@
import { Injectable, signal } from '@angular/core';
export type ViewLevel = 'galaxy' | 'system';
/**
* App-wide navigation state: which zoom level is active and what's currently selected.
* Scene components read these signals to drive rendering; UI (search, picking) writes to
* them to trigger navigation.
*/
@Injectable({ providedIn: 'root' })
export class NavigationStore {
readonly viewLevel = signal<ViewLevel>('galaxy');
readonly selectedStarId = signal<number | null>(null);
readonly selectedBodyId = signal<string | null>(null);
selectStar(starId: number | null): void {
this.selectedStarId.set(starId);
}
selectBody(bodyId: string | null): void {
this.selectedBodyId.set(bodyId);
}
setViewLevel(viewLevel: ViewLevel): void {
this.viewLevel.set(viewLevel);
}
}
+299
View File
@@ -0,0 +1,299 @@
[
{
"id": "mercury",
"systemStarId": 0,
"name": "Mercury",
"kind": "planet",
"radiusKm": 2439.4,
"orbit": {
"semiMajorAxisAu": 0.38709857292289906,
"eccentricity": 0.2056388859154261,
"inclinationDeg": 7.003502017708398,
"longitudeOfAscendingNodeDeg": 48.29980532881731,
"argumentOfPeriapsisDeg": 29.19561108948743,
"meanAnomalyAtEpochDeg": 103.9465145977117,
"epochJd": 2460676.5
}
},
{
"id": "venus",
"systemStarId": 0,
"name": "Venus",
"kind": "planet",
"radiusKm": 6051.84,
"orbit": {
"semiMajorAxisAu": 0.7233281694940689,
"eccentricity": 0.006746576710187382,
"inclinationDeg": 3.394393253252075,
"longitudeOfAscendingNodeDeg": 76.61185393039594,
"argumentOfPeriapsisDeg": 55.15075425343929,
"meanAnomalyAtEpochDeg": 280.0749102629981,
"epochJd": 2460676.5
}
},
{
"id": "earth",
"systemStarId": 0,
"name": "Earth",
"kind": "planet",
"radiusKm": 6371.01,
"orbit": {
"semiMajorAxisAu": 1.0009125494279245,
"eccentricity": 0.01756190256786036,
"inclinationDeg": 0.002977082685807642,
"longitudeOfAscendingNodeDeg": 190.1716375775361,
"argumentOfPeriapsisDeg": 272.9783142442708,
"meanAnomalyAtEpochDeg": 357.4122246804211,
"epochJd": 2460676.5
}
},
{
"id": "mars",
"systemStarId": 0,
"name": "Mars",
"kind": "planet",
"radiusKm": 3389.92,
"orbit": {
"semiMajorAxisAu": 1.5237367794976557,
"eccentricity": 0.09343026241683372,
"inclinationDeg": 1.847583389630714,
"longitudeOfAscendingNodeDeg": 49.48673257018439,
"argumentOfPeriapsisDeg": 286.7114828288203,
"meanAnomalyAtEpochDeg": 124.444888195349,
"epochJd": 2460676.5
}
},
{
"id": "jupiter",
"systemStarId": 0,
"name": "Jupiter",
"kind": "planet",
"radiusKm": 69911,
"orbit": {
"semiMajorAxisAu": 5.20282710471751,
"eccentricity": 0.04830624138918495,
"inclinationDeg": 1.303459692430419,
"longitudeOfAscendingNodeDeg": 100.5202095061913,
"argumentOfPeriapsisDeg": 273.6090683600047,
"meanAnomalyAtEpochDeg": 58.98282567286461,
"epochJd": 2460676.5
}
},
{
"id": "saturn",
"systemStarId": 0,
"name": "Saturn",
"kind": "planet",
"radiusKm": 58232,
"orbit": {
"semiMajorAxisAu": 9.555678383881443,
"eccentricity": 0.05522302318620472,
"inclinationDeg": 2.485819253218824,
"longitudeOfAscendingNodeDeg": 113.5595556634874,
"argumentOfPeriapsisDeg": 337.1663598259115,
"meanAnomalyAtEpochDeg": 264.9877650588944,
"epochJd": 2460676.5
}
},
{
"id": "uranus",
"systemStarId": 0,
"name": "Uranus",
"kind": "planet",
"radiusKm": 25362,
"orbit": {
"semiMajorAxisAu": 19.30135052232751,
"eccentricity": 0.04562415515293685,
"inclinationDeg": 0.7728969735590447,
"longitudeOfAscendingNodeDeg": 74.0126541901479,
"argumentOfPeriapsisDeg": 90.49593456147204,
"meanAnomalyAtEpochDeg": 255.8822481742851,
"epochJd": 2460676.5
}
},
{
"id": "neptune",
"systemStarId": 0,
"name": "Neptune",
"kind": "planet",
"radiusKm": 24624,
"orbit": {
"semiMajorAxisAu": 30.183632709237763,
"eccentricity": 0.01266098870528033,
"inclinationDeg": 1.774849832507214,
"longitudeOfAscendingNodeDeg": 131.9473970418126,
"argumentOfPeriapsisDeg": 268.1146665565159,
"meanAnomalyAtEpochDeg": 319.6858384317641,
"epochJd": 2460676.5
}
},
{
"id": "pluto",
"systemStarId": 0,
"name": "Pluto",
"kind": "dwarf",
"radiusKm": 1188.3,
"orbit": {
"semiMajorAxisAu": 39.28778257358678,
"eccentricity": 0.2438605399689669,
"inclinationDeg": 16.93906659887321,
"longitudeOfAscendingNodeDeg": 110.1714547539489,
"argumentOfPeriapsisDeg": 113.5754868232679,
"meanAnomalyAtEpochDeg": 51.93655343727463,
"epochJd": 2460676.5
}
},
{
"id": "moon",
"systemStarId": 0,
"name": "Moon",
"kind": "moon",
"radiusKm": 1737.53,
"orbit": {
"semiMajorAxisAu": 0.0025850155421324465,
"eccentricity": 0.04034704696474715,
"inclinationDeg": 5.004154175941119,
"longitudeOfAscendingNodeDeg": 0.531732989166472,
"argumentOfPeriapsisDeg": 6.557682814330136,
"meanAnomalyAtEpochDeg": 290.7825171697369,
"epochJd": 2460676.5
},
"parentBodyId": "earth"
},
{
"id": "phobos",
"systemStarId": 0,
"name": "Phobos",
"kind": "moon",
"radiusKm": 13.1,
"orbit": {
"semiMajorAxisAu": 0.00006269319601715431,
"eccentricity": 0.01559367482014246,
"inclinationDeg": 26.37766999967838,
"longitudeOfAscendingNodeDeg": 85.13091230996636,
"argumentOfPeriapsisDeg": 356.4060881660871,
"meanAnomalyAtEpochDeg": 342.6005509941174,
"epochJd": 2460676.5
},
"parentBodyId": "mars"
},
{
"id": "deimos",
"systemStarId": 0,
"name": "Deimos",
"kind": "moon",
"radiusKm": 7.8,
"orbit": {
"semiMajorAxisAu": 0.00015681797919999554,
"eccentricity": 0.0002637395029404219,
"inclinationDeg": 24.26157855280465,
"longitudeOfAscendingNodeDeg": 80.74605390828611,
"argumentOfPeriapsisDeg": 21.65246201037153,
"meanAnomalyAtEpochDeg": 273.8943716897566,
"epochJd": 2460676.5
},
"parentBodyId": "mars"
},
{
"id": "io",
"systemStarId": 0,
"name": "Io",
"kind": "moon",
"radiusKm": 1821.49,
"orbit": {
"semiMajorAxisAu": 0.0028210852715482176,
"eccentricity": 0.004228419931613188,
"inclinationDeg": 2.184262830697117,
"longitudeOfAscendingNodeDeg": 338.066968919613,
"argumentOfPeriapsisDeg": 164.6533514126175,
"meanAnomalyAtEpochDeg": 74.88524962049125,
"epochJd": 2460676.5
},
"parentBodyId": "jupiter"
},
{
"id": "europa",
"systemStarId": 0,
"name": "Europa",
"kind": "moon",
"radiusKm": 1560.8,
"orbit": {
"semiMajorAxisAu": 0.004486887602977481,
"eccentricity": 0.009740025569562362,
"inclinationDeg": 2.245018421035425,
"longitudeOfAscendingNodeDeg": 326.0277596344292,
"argumentOfPeriapsisDeg": 349.2045643684611,
"meanAnomalyAtEpochDeg": 40.72575250295771,
"epochJd": 2460676.5
},
"parentBodyId": "jupiter"
},
{
"id": "ganymede",
"systemStarId": 0,
"name": "Ganymede",
"kind": "moon",
"radiusKm": 2631.2,
"orbit": {
"semiMajorAxisAu": 0.007156574251479677,
"eccentricity": 0.001723569500229484,
"inclinationDeg": 2.332721144446764,
"longitudeOfAscendingNodeDeg": 339.4869144536639,
"argumentOfPeriapsisDeg": 0.02754303249978196,
"meanAnomalyAtEpochDeg": 355.7249344187845,
"epochJd": 2460676.5
},
"parentBodyId": "jupiter"
},
{
"id": "callisto",
"systemStarId": 0,
"name": "Callisto",
"kind": "moon",
"radiusKm": 2410.3,
"orbit": {
"semiMajorAxisAu": 0.012583604816889162,
"eccentricity": 0.007245216892383587,
"inclinationDeg": 1.94976938450231,
"longitudeOfAscendingNodeDeg": 336.7558983297979,
"argumentOfPeriapsisDeg": 32.65151937817475,
"meanAnomalyAtEpochDeg": 126.0198540648681,
"epochJd": 2460676.5
},
"parentBodyId": "jupiter"
},
{
"id": "titan",
"systemStarId": 0,
"name": "Titan",
"kind": "moon",
"radiusKm": 2575.5,
"orbit": {
"semiMajorAxisAu": 0.00816836581932601,
"eccentricity": 0.0288317905724529,
"inclinationDeg": 27.71117736818323,
"longitudeOfAscendingNodeDeg": 169.0716057233001,
"argumentOfPeriapsisDeg": 177.4798658320667,
"meanAnomalyAtEpochDeg": 32.18862839469676,
"epochJd": 2460676.5
},
"parentBodyId": "saturn"
},
{
"id": "triton",
"systemStarId": 0,
"name": "Triton",
"kind": "moon",
"radiusKm": 1352.6,
"orbit": {
"semiMajorAxisAu": 0.002371477351073512,
"eccentricity": 0.0001253161638938937,
"inclinationDeg": 129.1766133444893,
"longitudeOfAscendingNodeDeg": 222.3992799388247,
"argumentOfPeriapsisDeg": 97.422342803062,
"meanAnomalyAtEpochDeg": 273.1157438944629,
"epochJd": 2460676.5
},
"parentBodyId": "neptune"
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Star Map</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<app-root></app-root>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
+36
View File
@@ -0,0 +1,36 @@
/* You can add global styles to this file, and also import other style files */
@import 'tailwindcss';
/* Design tokens for the sci-fi "HUD" look shared across the app. `--color-*`/`--font-*` names
* automatically become Tailwind utilities (e.g. `--color-accent` -> `bg-accent`/`text-accent`). */
@theme {
--font-display: 'Orbitron', 'Segoe UI', sans-serif;
--font-body: 'JetBrains Mono', 'Segoe UI', monospace;
--color-void: #05070d;
--color-panel: #0b121f;
--color-accent: #4dd7ff;
--color-accent-dim: #1c4f66;
--color-text: #dbe6f3;
--color-muted: #7f93ab;
--color-border: #2a4a5c;
}
html,
body {
height: 100%;
margin: 0;
background: var(--color-void);
color: var(--color-text);
overflow: hidden;
font-family: var(--font-body);
}
/* Star name labels rendered by CSS2DRenderer (see StarLabelOverlay). These live outside
* Angular's view encapsulation as plain DOM nodes, so they're styled with Tailwind utility
* classes assigned directly in TypeScript rather than a scoped component stylesheet. */
.star-label-layer {
position: absolute;
top: 0;
left: 0;
}