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) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 15:42:58 +02:00
co-authored by Claude Opus 5.5
parent 28fa79e1e0
commit 74535accc5
4 changed files with 127 additions and 41 deletions
@@ -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 { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
@@ -34,7 +34,9 @@ const SATURN: BodyRecord = {
rates: {meanMotionDegPerDay: 0.033459683702669406, longitudeOfAscendingNodeDegPerDay: -0.000006848734291581108, argumentOfPeriapsisDegPerDay: 0.000021682266940451745}, 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]} 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. */ /** Stands in for the WebGPU engine: a scene, a camera, and the tick hook, driven by hand. */
class FakeEngineService { class FakeEngineService {
@@ -85,24 +87,29 @@ describe('BodyDetailSceneComponent', () => {
let engine: FakeEngineService; let engine: FakeEngineService;
let page: { planet: THREE.Mesh; ring?: THREE.Mesh; sunLight: THREE.DirectionalLight }; let page: { planet: THREE.Mesh; ring?: THREE.Mesh; sunLight: THREE.DirectionalLight };
let time: TimeStore; let time: TimeStore;
let route: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
async function open(id: string): Promise<void> { let fixture: ComponentFixture<BodyDetailSceneComponent>;
/** 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<void> {
engine = new FakeEngineService(); engine = new FakeEngineService();
route = new BehaviorSubject(convertToParamMap({ id }));
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [BodyDetailSceneComponent], imports: [BodyDetailSceneComponent],
providers: [ providers: [
{ provide: DataLoaderService, useClass: FakeDataLoaderService }, { 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) } } { provide: Router, useValue: { navigate: vi.fn().mockResolvedValue(true) } }
] ]
}).overrideComponent(BodyDetailSceneComponent, { }).overrideComponent(BodyDetailSceneComponent, {
// The scene alone: the info panel and the dock are tested on their own. // The scene alone, unless asked: the info panel and the dock are tested on their own.
set: { providers: [{ provide: EngineService, useValue: engine }], imports: [], template: '<canvas #canvas></canvas>' } set: whole ? { providers: [{ provide: EngineService, useValue: engine }] } : { providers: [{ provide: EngineService, useValue: engine }], imports: [], template: '<canvas #canvas></canvas>' }
}); });
time = TestBed.inject(TimeStore); time = TestBed.inject(TimeStore);
time.setRate(0); time.setRate(0);
time.setDate(new Date('2025-06-01T12:00Z')); time.setDate(new Date(date));
const fixture = TestBed.createComponent(BodyDetailSceneComponent); fixture = TestBed.createComponent(BodyDetailSceneComponent);
fixture.detectChanges(); fixture.detectChanges();
await flushAsync(); await flushAsync();
page = fixture.componentInstance as unknown as typeof page; 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.planet.quaternion.angleTo(planet)).toBeLessThan(1e-9);
expect(page.sunLight.position.clone().normalize().angleTo(sun)).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);
});
}); });
@@ -62,9 +62,10 @@ const SUN_LIGHT_POSITION = new THREE.Vector3(4, 3, 5);
</a> </a>
</div> </div>
} }
<!-- Search and what has been kept: there is no scene readout here, the info panel is <!-- Search, what has been kept and the clock: there is no scene readout here, the info
the reading, and the panel's own control is what keeps this body. --> panel is the reading, and the panel's own control is what keeps this body. The body is
<app-hud-dock (bookmarkChosen)="goToBookmark($event)" /> 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> </div>
` `
}) })
@@ -101,6 +102,10 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
readonly viewModel = signal<BodyDetailViewModel | undefined>(undefined); readonly viewModel = signal<BodyDetailViewModel | undefined>(undefined);
readonly notFound = signal(false); 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( constructor(
private readonly engine: EngineService, 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.body = this.bodies.find((body) => body.id === viewModel.id);
this.planet?.rotation.set(0, 0, 0); this.planet?.rotation.set(0, 0, 0);
this.sunLight?.position.copy(SUN_LIGHT_POSITION); this.sunLight?.position.copy(SUN_LIGHT_POSITION);
this.cameraToSunSide = true;
this.disposeRing(); this.disposeRing();
this.disposeGlow(); this.disposeGlow();
@@ -279,15 +285,24 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
*/ */
private tick(deltaSeconds: number): void { private tick(deltaSeconds: number): void {
this.controls?.update(); 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; return;
} }
const sunAzimuth = Math.atan2(SUN_LIGHT_POSITION.x, SUN_LIGHT_POSITION.z); 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()); this.sunLight.position.multiplyScalar(SUN_LIGHT_POSITION.length());
} else { } else {
this.planet.rotation.y += deltaSeconds * 0.08; 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 { private observeResize(canvas: HTMLCanvasElement): void {
@@ -77,6 +77,17 @@ describe('HudDockComponent', () => {
expect(tabNames()).toEqual(['Search', 'Readout', 'Routes', 'Bookmarks', 'Display']); 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', () => { it('opens the default tab on mount and renders the readout from its inputs', () => {
setReadout(); setReadout();
fixture.componentRef.setInput('defaultTab', 'readout'); fixture.componentRef.setInput('defaultTab', 'readout');
+11 -4
View File
@@ -268,6 +268,7 @@ function isWideViewport(): boolean {
aria-labelledby="dock-tab-display" 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" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3"
> >
@if (display()) {
<p class="type-label text-muted">Layers</p> <p class="type-label text-muted">Layers</p>
<div class="mt-2 flex flex-wrap gap-2"> <div class="mt-2 flex flex-wrap gap-2">
@for (layer of layers; track layer.key) { @for (layer of layers; track layer.key) {
@@ -292,10 +293,11 @@ function isWideViewport(): boolean {
</button> </button>
} }
</div> </div>
}
<!-- The clock. Orbits and rotations are both functions of a date, so this is the <!-- The clock. Orbits and rotations are both functions of a date, so this is the
difference between a still picture and an orrery. --> difference between a still picture and an orrery. -->
<p class="type-label mt-4 text-muted">Clock</p> <p class="type-label text-muted" [class.mt-4]="display()">Clock</p>
<!-- Radios rather than buttons: the rates are one-of-four, and the native control <!-- Radios rather than buttons: the rates are one-of-four, and the native control
carries that to a screen reader and to the arrow keys without any script. --> carries that to a screen reader and to the arrow keys without any script. -->
<div <div
@@ -467,8 +469,13 @@ export class HudDockComponent implements OnInit {
readonly range = input(''); readonly range = input('');
/** The date the sky is drawn for; empty while the map is drawn for the present. */ /** The date the sky is drawn for; empty while the map is drawn for the present. */
readonly date = input(''); readonly date = input('');
/** Layer state; `null` means the surface has no layers to toggle and no Display tab. */ /**
* Layer state; `null` means the surface has no layers to toggle, and no Display tab unless it
* has the clock.
*/
readonly display = input<HudDisplay | null>(null); readonly display = input<HudDisplay | null>(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. */ /** Which panel is open on a wide viewport when the dock mounts. */
readonly defaultTab = input<DockTab | null>(null); readonly defaultTab = input<DockTab | null>(null);
/** Routing: what the scene found, what it offers for the fields, and where the view is. */ /** 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 // 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. // anything at all, and a tab that appears once you already know is a tab that never taught.
'bookmarks', 'bookmarks',
...(this.display() ? (['display'] as const) : []), ...(this.display() || this.clock() ? (['display'] as const) : []),
]); ]);
readonly activeTab = signal<DockTab | null>(null); readonly activeTab = signal<DockTab | null>(null);
@@ -522,7 +529,7 @@ export class HudDockComponent implements OnInit {
} }
tabLabel(tab: DockTab): string { tabLabel(tab: DockTab): string {
return TAB_LABELS[tab]; return tab === 'display' && !this.display() ? 'Clock' : TAB_LABELS[tab];
} }
isOn(key: keyof HudDisplay): boolean { isOn(key: keyof HudDisplay): boolean {