diff --git a/src/app/features/body-detail/body-detail-scene.component.spec.ts b/src/app/features/body-detail/body-detail-scene.component.spec.ts index ac061fb..af961ad 100644 --- a/src/app/features/body-detail/body-detail-scene.component.spec.ts +++ b/src/app/features/body-detail/body-detail-scene.component.spec.ts @@ -1,4 +1,4 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { BehaviorSubject } from 'rxjs'; import * as THREE from 'three/webgpu'; @@ -34,7 +34,9 @@ const SATURN: BodyRecord = { rates: {meanMotionDegPerDay: 0.033459683702669406, longitudeOfAscendingNodeDegPerDay: -0.000006848734291581108, argumentOfPeriapsisDegPerDay: 0.000021682266940451745}, rotationalElements: {poleRaDeg: [40.589, -0.036, 0], poleDecDeg: [83.537, -0.004, 0], primeMeridianDeg: [38.9, 810.7939024, 0]} }; -const BODIES = [EARTH, SATURN]; +// Eris as it is shipped for this page's purposes: no IAU model, so its page keeps its own light. +const ERIS: BodyRecord = { ...EARTH, id: 'eris', name: 'Eris', kind: 'dwarf', radiusKm: 1163, rotationalElements: undefined }; +const BODIES = [EARTH, SATURN, ERIS]; /** Stands in for the WebGPU engine: a scene, a camera, and the tick hook, driven by hand. */ class FakeEngineService { @@ -85,24 +87,29 @@ describe('BodyDetailSceneComponent', () => { let engine: FakeEngineService; let page: { planet: THREE.Mesh; ring?: THREE.Mesh; sunLight: THREE.DirectionalLight }; let time: TimeStore; + let route: BehaviorSubject>; - async function open(id: string): Promise { + let fixture: ComponentFixture; + + /** Opens a body's page at a date; `whole` keeps the page's own template, dock and panel included. */ + async function open(id: string, date = '2025-06-01T12:00Z', whole = false): Promise { engine = new FakeEngineService(); + route = new BehaviorSubject(convertToParamMap({ id })); TestBed.configureTestingModule({ imports: [BodyDetailSceneComponent], providers: [ { provide: DataLoaderService, useClass: FakeDataLoaderService }, - { provide: ActivatedRoute, useValue: { paramMap: new BehaviorSubject(convertToParamMap({ id })) } }, + { provide: ActivatedRoute, useValue: { paramMap: route } }, { provide: Router, useValue: { navigate: vi.fn().mockResolvedValue(true) } } ] }).overrideComponent(BodyDetailSceneComponent, { - // The scene alone: the info panel and the dock are tested on their own. - set: { providers: [{ provide: EngineService, useValue: engine }], imports: [], template: '' } + // The scene alone, unless asked: the info panel and the dock are tested on their own. + set: whole ? { providers: [{ provide: EngineService, useValue: engine }] } : { providers: [{ provide: EngineService, useValue: engine }], imports: [], template: '' } }); time = TestBed.inject(TimeStore); time.setRate(0); - time.setDate(new Date('2025-06-01T12:00Z')); - const fixture = TestBed.createComponent(BodyDetailSceneComponent); + time.setDate(new Date(date)); + fixture = TestBed.createComponent(BodyDetailSceneComponent); fixture.detectChanges(); await flushAsync(); page = fixture.componentInstance as unknown as typeof page; @@ -128,4 +135,50 @@ describe('BodyDetailSceneComponent', () => { expect(page.planet.quaternion.angleTo(planet)).toBeLessThan(1e-9); expect(page.sunLight.position.clone().normalize().angleTo(sun)).toBeLessThan(1e-9); }); + + it('follows the clock once the page is open, as it runs or is set', async () => { + await open('earth'); + time.setDate(new Date('2025-06-01T18:00Z')); + engine.tick(0.016); + const planet = new THREE.Quaternion(); + expect(bodyPageView(EARTH, BODIES, time.julianDate(), Math.atan2(4, 5), planet, new THREE.Vector3())).toBe(true); + // Six hours on, a quarter turn of Earth: a page frozen at its first frame is 90 degrees out. + expect(page.planet.quaternion.angleTo(planet)).toBeLessThan(1e-9); + }); + + it('puts the page’s own light back when the next body shown has no IAU model to place its Sun', async () => { + await open('earth'); + expect(page.sunLight.position.distanceTo(new THREE.Vector3(4, 3, 5))).toBeGreaterThan(0.1); + route.next(convertToParamMap({ id: 'eris' })); + await flushAsync(); + engine.tick(0.016); + expect(page.sunLight.position.distanceTo(new THREE.Vector3(4, 3, 5))).toBeLessThan(1e-9); + }); + + it('says on its dock the date the body is drawn for, and nothing at the present, and offers the clock', async () => { + await open('saturn', '2032-06-01T12:00Z', true); + fixture.detectChanges(); + const host = fixture.nativeElement as HTMLElement; + expect(host.querySelector('[data-testid="hud-date"]')?.textContent).toContain('2032-06-01'); + expect([...host.querySelectorAll('[role="tab"]')].map((tab) => tab.textContent?.trim())).toContain('Clock'); + time.reset(); + engine.tick(0.016); + fixture.detectChanges(); + expect(host.querySelector('[data-testid="hud-date"]')).toBeNull(); + }); + + it('opens Saturn on the face of its rings the Sun lights: the south, from 2025 to 2039', async () => { + await open('saturn', '2032-06-01T12:00Z'); + const camera = engine.getCamera(); + // The Sun 26.7 degrees south of the rings, and the camera with it rather than 11 degrees north. + expect(page.sunLight.position.y).toBeLessThan(0); + expect(camera.position.y).toBeLessThan(0); + + route.next(convertToParamMap({ id: 'earth' })); + await flushAsync(); + engine.tick(0.016); + // June: Earth's Sun is in the north, and so is the camera again. + expect(page.sunLight.position.y).toBeGreaterThan(0); + expect(camera.position.y).toBeGreaterThan(0); + }); }); diff --git a/src/app/features/body-detail/body-detail-scene.component.ts b/src/app/features/body-detail/body-detail-scene.component.ts index 1ed10cf..f8828db 100644 --- a/src/app/features/body-detail/body-detail-scene.component.ts +++ b/src/app/features/body-detail/body-detail-scene.component.ts @@ -62,9 +62,10 @@ const SUN_LIGHT_POSITION = new THREE.Vector3(4, 3, 5); } - - + + ` }) @@ -101,6 +102,10 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { 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, @@ -188,6 +193,7 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { 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(); @@ -279,15 +285,24 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { */ private tick(deltaSeconds: number): void { this.controls?.update(); - if (!this.planet) { + 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 && this.sunLight && bodyPageView(this.body, this.bodies, this.time.julianDate(), sunAzimuth, this.planet.quaternion, this.sunLight.position)) { + 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 { diff --git a/src/app/features/hud/hud-dock.component.spec.ts b/src/app/features/hud/hud-dock.component.spec.ts index 468a3b4..5ef5196 100644 --- a/src/app/features/hud/hud-dock.component.spec.ts +++ b/src/app/features/hud/hud-dock.component.spec.ts @@ -77,6 +77,17 @@ describe('HudDockComponent', () => { expect(tabNames()).toEqual(['Search', 'Readout', 'Routes', 'Bookmarks', 'Display']); }); + it('offers the clock alone, as a Clock tab, to a surface with no layers', () => { + fixture.componentRef.setInput('clock', true); + fixture.componentRef.setInput('defaultTab', 'display'); + fixture.detectChanges(); + expect(tabNames()).toEqual(['Search', 'Bookmarks', 'Clock']); + const panel = host().querySelector('#dock-panel-display')!; + expect(panel.querySelector('[role="radiogroup"][aria-label="Clock rate"]')).not.toBeNull(); + expect(panel.querySelector('#clock-date')).not.toBeNull(); + expect(panel.textContent).not.toContain('Layers'); + }); + it('opens the default tab on mount and renders the readout from its inputs', () => { setReadout(); fixture.componentRef.setInput('defaultTab', 'readout'); diff --git a/src/app/features/hud/hud-dock.component.ts b/src/app/features/hud/hud-dock.component.ts index a8b774c..b282773 100644 --- a/src/app/features/hud/hud-dock.component.ts +++ b/src/app/features/hud/hud-dock.component.ts @@ -268,34 +268,36 @@ function isWideViewport(): boolean { aria-labelledby="dock-tab-display" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3" > -

Layers

-
- @for (layer of layers; track layer.key) { - - } -
+ @if (display()) { +

Layers

+
+ @for (layer of layers; track layer.key) { + + } +
+ } -

Clock

+

Clock

(null); + /** The clock without the layers, for a surface that is drawn at its date: the tab is then "Clock". */ + readonly clock = input(false); /** Which panel is open on a wide viewport when the dock mounts. */ readonly defaultTab = input(null); /** Routing: what the scene found, what it offers for the fields, and where the view is. */ @@ -497,7 +504,7 @@ export class HudDockComponent implements OnInit { // Always offered, even with nothing in it: it is the only place that says the map can keep // anything at all, and a tab that appears once you already know is a tab that never taught. 'bookmarks', - ...(this.display() ? (['display'] as const) : []), + ...(this.display() || this.clock() ? (['display'] as const) : []), ]); readonly activeTab = signal(null); @@ -522,7 +529,7 @@ export class HudDockComponent implements OnInit { } tabLabel(tab: DockTab): string { - return TAB_LABELS[tab]; + return tab === 'display' && !this.display() ? 'Clock' : TAB_LABELS[tab]; } isOn(key: keyof HudDisplay): boolean {