Merge branch 'feat/hud-2d' into feat/hud-about

This commit is contained in:
2026-08-21 15:13:28 +02:00
3 changed files with 59 additions and 19 deletions
@@ -4,7 +4,13 @@ import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { dateToJulianDate } from '../../shared/astro/constants';
import { galacticCentrePositionPc, galacticToEquatorial, MILKY_WAY_ARMS, SUN_GALACTOCENTRIC_RADIUS_PC } from '../../shared/astro/galaxy';
import {
GALACTIC_BASIS_EQUATORIAL,
MILKY_WAY_ARMS,
SUN_GALACTOCENTRIC_RADIUS_PC,
galacticCentrePositionPc,
galacticToEquatorial
} from '../../shared/astro/galaxy';
import { DataLoaderService } from '../../core/data/data-loader.service';
import { EngineService, SceneCamera } from '../../core/engine/engine.service';
import { BodyRecord } from '../../shared/models/body.model';
@@ -630,7 +636,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// from inside it, so it cannot also be the sky behind a view of the Galaxy from outside.
this.engine.getScene().backgroundIntensity = display.sky ? 1 - this.galacticStrength : 0;
this.applyGalaxyDepthRange(camera, distancePc);
this.applyGalaxyDepthRange(distancePc);
const level: ViewLevel = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD ? 'galactic' : 'galaxy';
if (this.navigationStore.viewLevel() !== level && !this.systemGroup.visible) {
this.navigationStore.setViewLevel(level);
@@ -657,19 +663,22 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
return halfHeight / Math.tan((this.engine.getPerspectiveCamera().fov * Math.PI) / 360);
}
private applyGalaxyDepthRange(camera: SceneCamera, distancePc: number): void {
// The plan view sets its own depth range, symmetric about the camera; see `frameOrthographic`.
if (this.engine.currentProjection === 'orthographic') {
return;
}
private applyGalaxyDepthRange(distancePc: number): void {
const near = THREE.MathUtils.clamp(distancePc / 2000, GALAXY_NEAR_PC, GALACTIC_NEAR_PC);
const far = THREE.MathUtils.clamp(distancePc * 8, GALAXY_FAR_PC, GALACTIC_FAR_PC);
// Written to the perspective camera whichever one is live, because it is the one this range
// is reasoned in and the one `frameOrthographic` reads its own from. Skipping it under a plan
// view left the far plane wherever it was when the projection changed, so flying out to the
// Galaxy from there clipped away most of it.
const perspective = this.engine.getPerspectiveCamera();
// Only when it has drifted enough to matter, so a slow zoom isn't rebuilding the projection
// matrix on every frame of it.
if (Math.abs(near - camera.near) > camera.near * 0.05 || Math.abs(far - camera.far) > camera.far * 0.05) {
camera.near = near;
camera.far = far;
camera.updateProjectionMatrix();
if (Math.abs(near - perspective.near) > perspective.near * 0.05 || Math.abs(far - perspective.far) > perspective.far * 0.05) {
perspective.near = near;
perspective.far = far;
perspective.updateProjectionMatrix();
// The plan view's own range is symmetric about the camera and derived from this one; see
// `frameOrthographic`. It is re-derived every frame, so there is nothing to do here.
}
}
@@ -1074,9 +1083,22 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
if (plan) {
// Straight down the plane's normal, from where the camera already was.
const normal = this.systemGroup.visible && this.systemRenderer ? new THREE.Vector3(0, 0, 1).applyQuaternion(this.systemRenderer.referenceFrame) : new THREE.Vector3(0, 0, 1);
// Down the normal of the plane this scale is actually read against. Inside a system that
// is the system's own orbital plane; outside it, the galactic plane — whose normal is the
// north galactic pole, not the celestial one. Defaulting to the scene's own z would have
// looked down the Earth's rotation axis and called it the plane of the Galaxy.
const galactic = GALACTIC_BASIS_EQUATORIAL;
const inSystem = this.systemGroup.visible && this.systemRenderer;
const normal = inSystem
? new THREE.Vector3(0, 0, 1).applyQuaternion(this.systemRenderer!.referenceFrame)
: new THREE.Vector3(galactic.z.x, galactic.z.y, galactic.z.z);
next.position.copy(target).add(normal.multiplyScalar(distance));
next.up.set(0, 1, 0).applyQuaternion(this.systemGroup.visible && this.systemRenderer ? this.systemRenderer.referenceFrame : new THREE.Quaternion());
if (inSystem) {
next.up.set(0, 1, 0).applyQuaternion(this.systemRenderer!.referenceFrame);
} else {
// Towards the galactic centre, so the plan is oriented the way the model is described.
next.up.set(galactic.x.x, galactic.x.y, galactic.x.z);
}
}
next.lookAt(target);
this.controls.update();
@@ -210,6 +210,21 @@ describe('HudDockComponent', () => {
expect(host().querySelector('[aria-label="Forget Sirius"]')?.getAttribute('aria-pressed')).toBe('true');
});
it('keeps the one system whose catalogue id is zero, which truthiness would have lost', () => {
// The Sun is star 0. A `@if (id; as ...)` reads that as "no star" and hides the control.
const bookmarks = TestBed.inject(BookmarksStore);
setReadout();
fixture.componentRef.setInput('title', 'Sol');
fixture.componentRef.setInput('keepableStarId', 0);
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
host().querySelector<HTMLButtonElement>('[aria-label="Keep Sol"]')?.click();
fixture.detectChanges();
expect(bookmarks.has('star', 0)).toBe(true);
});
it('offers nothing to keep where the readout is a scale rather than a place', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'readout');
+9 -6
View File
@@ -94,16 +94,19 @@ function isWideViewport(): boolean {
<p class="type-label text-muted">{{ eyebrow() }}</p>
<div class="mt-1 flex items-start gap-2">
<p data-testid="hud-title" class="min-w-0 flex-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
@if (keepableStarId(); as starId) {
<!-- Against null, not against falsiness: the Sun's catalogue id is 0, and a
truthiness test is what would quietly make the Solar System the one
system nobody could keep. -->
@if (keepableStarId() !== null) {
<button
type="button"
[attr.aria-label]="(bookmarks.has('star', starId) ? 'Forget ' : 'Keep ') + title()"
[attr.aria-pressed]="bookmarks.has('star', starId)"
(click)="bookmarks.toggle({ kind: 'star', id: starId, name: title() })"
[attr.aria-label]="(bookmarks.has('star', keepableStarId()!) ? 'Forget ' : 'Keep ') + title()"
[attr.aria-pressed]="bookmarks.has('star', keepableStarId()!)"
(click)="bookmarks.toggle({ kind: 'star', id: keepableStarId()!, name: title() })"
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
[class]="bookmarks.has('star', starId) ? 'text-accent' : 'text-muted hover:text-accent'"
[class]="bookmarks.has('star', keepableStarId()!) ? 'text-accent' : 'text-muted hover:text-accent'"
>
<app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('star', starId)" />
<app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('star', keepableStarId()!)" />
</button>
}
</div>