import { AfterViewInit, Component, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Subscription } from 'rxjs';
import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { DataLoaderService } from '../../core/data/data-loader.service';
import { EngineService } from '../../core/engine/engine.service';
import { bodyPageView } from '../../shared/rendering/body-orientation';
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, saturnRing } 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 { Bookmark } from '../../shared/state/bookmarks.store';
import { NavigationStore } from '../../shared/state/navigation.store';
import { TimeStore } from '../../shared/state/time.store';
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
import { HudDockComponent } from '../hud/hud-dock.component';
import { BodyDetailViewModel } from './body-detail.model';
import { buildBodyViewModel } from './body-view-model';
import { InfoPanelComponent } from './info-panel.component';
/** 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']);
/** The body is drawn at unit radius here, so the halo's extent is its multiple directly. */
const GLOW_SCALE = 2.6;
/** Where the page's light stands, and the Sun with it wherever the body's real one is known. */
const SUN_LIGHT_POSITION = new THREE.Vector3(4, 3, 5);
/**
* 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: [ChevronIconComponent, HudDockComponent, InfoPanelComponent, RouterLink],
template: `
@if (viewModel()) {
} @else if (notFound()) {
No record
That id isn't in the catalog — it may have been renamed or mistyped.
Back to the galaxy
}
`
})
export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
/** A kept place, revisited from this page: a star means leaving it for the map. */
goToBookmark(bookmark: Bookmark): void {
if (bookmark.kind === 'star') {
this.navigationStore.selectStar(Number(bookmark.id));
void this.router.navigate(['/']);
} else {
void this.router.navigate(['/body', String(bookmark.id)]);
}
}
private readonly canvasRef = viewChild.required>('canvas');
private controls?: OrbitControls;
private scene?: THREE.Scene;
private planet?: THREE.Mesh;
private planetMaterial?: THREE.MeshStandardMaterial;
private sunLight?: THREE.DirectionalLight;
/** The solar-system record behind the body shown, which is what can be turned by its real pole. */
private body?: BodyRecord;
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(undefined);
readonly notFound = signal(false);
/** The date the body is drawn for, as the dock's strip prints it; empty at the present. */
readonly date = signal('');
/** Set when a body is shown, until the next frame has put the camera on its Sun's side. */
private cameraToSunSide = false;
constructor(
private readonly engine: EngineService,
private readonly dataLoader: DataLoaderService,
private readonly route: ActivatedRoute,
private readonly router: Router,
private readonly navigationStore: NavigationStore,
private readonly time: TimeStore
) {}
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 {
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;
}
// Shared with the system view's object card, so the same body cannot read differently there.
const viewModel = buildBodyViewModel(id, { bodies: this.bodies, exoplanets: this.exoplanets, stars: this.stars });
if (!viewModel) {
this.viewModel.set(undefined);
this.notFound.set(true);
return;
}
this.viewModel.set(viewModel);
if (viewModel.hostStarId !== undefined) {
this.navigationStore.selectStar(viewModel.hostStarId);
}
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;
}
// Real photography wherever it exists, and a surface derived from the body's own measured
// properties wherever it does not — which is every exoplanet, since none has had its
// surface imaged, and the handful of moons no probe returned a usable map of.
const realTexturePath = bodyTexturePath(viewModel.id);
this.planetMaterial.map = realTexturePath ? loadCachedTexture(realTexturePath) : planetTexture(viewModel.appearance);
// The texture supplies its own colour, so the base stays white rather than tinting it twice.
this.planetMaterial.color.set(0xffffff);
// A fluid envelope scatters light more evenly than a solid surface does.
this.planetMaterial.roughness = GAS_GIANT_IDS.has(viewModel.id) || viewModel.appearance.palette.structure === 'banded' ? 0.55 : 0.85;
this.planetMaterial.needsUpdate = true;
// Back to the page's own light and a sphere at rest; `tick` turns both where the IAU says how.
this.body = this.bodies.find((body) => body.id === viewModel.id);
this.planet?.rotation.set(0, 0, 0);
this.sunLight?.position.copy(SUN_LIGHT_POSITION);
this.cameraToSunSide = true;
this.disposeRing();
this.disposeGlow();
if (this.scene) {
if (viewModel.id === 'saturn' && this.body) {
// Flat in the page's horizontal, which is Saturn's equator: the planet is drawn pole up, at
// unit radius. They used to reach 2.6 radii out; the outermost ring the texture draws is 2.42.
this.ring = saturnRing(this.body.radiusKm, 1);
this.scene.add(this.ring);
}
const atmosphereColor = atmosphereColorFor(viewModel.id);
if (atmosphereColor !== undefined) {
this.glow = createGlowSprite(atmosphereColor, GLOW_SCALE);
this.scene.add(this.glow);
}
}
}
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 {
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));
this.sunLight = new THREE.DirectionalLight(0xfff4e0, 1.6);
this.sunLight.position.copy(SUN_LIGHT_POSITION);
scene.add(this.sunLight);
const geometry = new THREE.SphereGeometry(1, 64, 48);
const viewModel = this.viewModel();
this.planetMaterial = new THREE.MeshStandardMaterial({
// White, always: the map that arrives a moment later carries the colour, whether it is a
// photograph or a surface derived from the body's own measurements.
color: 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();
}
/**
* A body the IAU gives rotational elements for is turned as it is at the map's date, under its
* real Sun, at the rate the map's clock runs (see `bodyPageView`). Any other — an exoplanet, or
* Eris, Haumea or Makemake — turns slowly for show, as the page always turned them.
*/
private tick(deltaSeconds: number): void {
this.controls?.update();
this.date.set(this.time.atNow() ? '' : this.time.date().toISOString().slice(0, 10));
if (!this.planet || !this.sunLight) {
return;
}
const sunAzimuth = Math.atan2(SUN_LIGHT_POSITION.x, SUN_LIGHT_POSITION.z);
if (this.body && bodyPageView(this.body, this.bodies, this.time.julianDate(), sunAzimuth, this.planet.quaternion, this.sunLight.position)) {
this.sunLight.position.multiplyScalar(SUN_LIGHT_POSITION.length());
} else {
this.planet.rotation.y += deltaSeconds * 0.08;
}
if (this.cameraToSunSide) {
// Above or below the equator, whichever side the Sun is on. Held above it, the page opened
// Saturn on the unlit face of its rings from 2025 until 2039, while the Sun is south of
// them — the face Earth does not see either.
const camera = this.engine.getCamera();
camera.position.y = Math.abs(camera.position.y) * (this.sunLight.position.y < 0 ? -1 : 1);
this.cameraToSunSide = false;
}
}
private observeResize(canvas: HTMLCanvasElement): void {
this.resizeObserver = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
this.engine.resize(width, height);
});
this.resizeObserver.observe(canvas);
}
}