From 74535accc55dfa69291268eca4f63614a9402e12 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Wed, 30 Sep 2026 15:42:58 +0200 Subject: [PATCH] Show the date and the clock on a body's page, and open it on the side of the equator its Sun lights 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) --- .../body-detail-scene.component.spec.ts | 69 ++++++++++++++++--- .../body-detail-scene.component.ts | 25 +++++-- .../features/hud/hud-dock.component.spec.ts | 11 +++ src/app/features/hud/hud-dock.component.ts | 63 +++++++++-------- 4 files changed, 127 insertions(+), 41 deletions(-) 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 {