Since the page turns a body as it stands at the map's date, it has been drawn for a date it never showed, at a rate it gave no way to change: set to 2032 at a day a second in the system view, Saturn's page ran on at that rate with only Search and Bookmarks on its dock, and at a month a second Earth turned about 183 degrees a frame at 60 Hz. The dock now takes `clock`, which offers the clock without the layers, as a tab named Clock, and the page binds it with the date strip the system view already has. Measured on :4301 at 2032-06-01: the strip reads "Date 2032-06-01", the tabs are Search, Bookmarks and Clock, and the Clock panel has the four rates and the date field; Back to now clears the strip. The camera opened 11.3 degrees north of the equator whatever the season, which since 2025 put Saturn's page on the unlit face of its rings, and will until 2039. When a body is shown, the next frame puts the camera on the side of the equator the Sun is on: measured on Saturn's page, the Sun at -26.71 degrees on 2032-06-01 and the camera at -11.31; Earth's in June stays north. Tests, each proved by a guarded mutant that fails it: - the page follows the clock after its first frame (frozen at the first frame: 90 degrees out); - a body with no IAU model shown after Earth gets the page's own light back (left at Earth's Sun); - the page's dock shows the date and a Clock tab, and no date at the present (date never set, or the dock bound without the clock); - the dock offers the clock alone as a Clock tab (no tab, or a tab still named Display); - Saturn's page in 2032 opens with the camera and the Sun both south (camera held north). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
316 lines
13 KiB
TypeScript
316 lines
13 KiB
TypeScript
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: `
|
|
<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="hud-brackets hud-acquire hud-surface absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] p-4 font-body text-text">
|
|
<p class="type-eyebrow text-accent">No record</p>
|
|
<p class="mt-2 text-sm text-muted">That id isn't in the catalog — it may have been renamed or mistyped.</p>
|
|
<a
|
|
routerLink="/"
|
|
class="type-label mt-4 inline-flex items-center gap-2 border border-border/60 px-3 py-1.5 text-muted transition-colors hover:border-accent/70 hover:text-accent focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
|
>
|
|
<app-chevron-icon class="h-3 w-3" direction="left" />
|
|
Back to the galaxy
|
|
</a>
|
|
</div>
|
|
}
|
|
<!-- Search, what has been kept and the clock: there is no scene readout here, the info
|
|
panel is the reading, and the panel's own control is what keeps this body. The body is
|
|
drawn at the clock's date and turns at its rate, so both are shown and can be set here. -->
|
|
<app-hud-dock [date]="date()" [clock]="true" (bookmarkChosen)="goToBookmark($event)" />
|
|
</div>
|
|
`
|
|
})
|
|
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<ElementRef<HTMLCanvasElement>>('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<BodyDetailViewModel | undefined>(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<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;
|
|
}
|
|
|
|
// 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<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));
|
|
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);
|
|
}
|
|
}
|