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:
@@ -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<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();
|
||||
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: '<canvas #canvas></canvas>' }
|
||||
// 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: '<canvas #canvas></canvas>' }
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,9 +62,10 @@ const SUN_LIGHT_POSITION = new THREE.Vector3(4, 3, 5);
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
<!-- Search and what has been kept: there is no scene readout here, the info panel is
|
||||
the reading, and the panel's own control is what keeps this body. -->
|
||||
<app-hud-dock (bookmarkChosen)="goToBookmark($event)" />
|
||||
<!-- 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>
|
||||
`
|
||||
})
|
||||
@@ -101,6 +102,10 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
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,
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<p class="type-label text-muted">Layers</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@for (layer of layers; track layer.key) {
|
||||
<button
|
||||
type="button"
|
||||
[attr.aria-pressed]="isOn(layer.key)"
|
||||
(click)="toggleLayer(layer.key)"
|
||||
class="type-label flex items-center gap-2 border px-3 py-1.5 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
[class]="
|
||||
isOn(layer.key)
|
||||
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
||||
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
||||
"
|
||||
>
|
||||
<!-- The state mark: a filled tick when the layer is drawn, hollow when it is not. -->
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-1.5 w-1.5 border border-current"
|
||||
[class.bg-current]="isOn(layer.key)"
|
||||
></span>
|
||||
{{ layer.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (display()) {
|
||||
<p class="type-label text-muted">Layers</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@for (layer of layers; track layer.key) {
|
||||
<button
|
||||
type="button"
|
||||
[attr.aria-pressed]="isOn(layer.key)"
|
||||
(click)="toggleLayer(layer.key)"
|
||||
class="type-label flex items-center gap-2 border px-3 py-1.5 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
[class]="
|
||||
isOn(layer.key)
|
||||
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
||||
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
||||
"
|
||||
>
|
||||
<!-- The state mark: a filled tick when the layer is drawn, hollow when it is not. -->
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-1.5 w-1.5 border border-current"
|
||||
[class.bg-current]="isOn(layer.key)"
|
||||
></span>
|
||||
{{ layer.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- The clock. Orbits and rotations are both functions of a date, so this is the
|
||||
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
|
||||
carries that to a screen reader and to the arrow keys without any script. -->
|
||||
<div
|
||||
@@ -467,8 +469,13 @@ export class HudDockComponent implements OnInit {
|
||||
readonly range = input('');
|
||||
/** The date the sky is drawn for; empty while the map is drawn for the present. */
|
||||
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);
|
||||
/** 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<DockTab | null>(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<DockTab | null>(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 {
|
||||
|
||||
Reference in New Issue
Block a user