diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts index bfbd71d..8f9002c 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts @@ -12,6 +12,7 @@ import { DeepSkyRecord } from '../../shared/models/deepsky.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { StarRecord } from '../../shared/models/star.model'; import { NavigationStore } from '../../shared/state/navigation.store'; +import { TimeStore } from '../../shared/state/time.store'; import { LinkBudget } from '../../shared/astro/jump-links'; import { HudDisplay } from '../hud/hud-dock.component'; import { GalaxySystemSceneComponent } from './galaxy-system-scene.component'; @@ -836,12 +837,22 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { navigationStore.selectStar(SUN.id); await flushAsync(); await advanceFrames(engine, 2.5); - expect(note()).toBe('Orbits propagated from JPL mean elements, the planets’ fit for 3000 BC to AD 3000, and the SBDB’s osculating ones for Ceres, Eris, Haumea and Makemake, to the current date.'); + expect(note()).toMatch(/^Orbits propagated from JPL mean elements, the planets’ fit for 3000 BC to AD 3000, and the SBDB’s osculating ones for Ceres, Eris, Haumea and Makemake, to now, \d{4}-\d\d-\d\d \d\d:\d\d UTC\.$/); navigationStore.selectStar(ALPHA_CENTAURI.id); await flushAsync(); await advanceFrames(engine, 5); - expect(note()).toBe('Orbits propagated from published elements to the current date.'); + expect(note()).toMatch(/^Orbits propagated from published elements to now, \d{4}-\d\d-\d\d \d\d:\d\d UTC\.$/); + }); + + it('names the date the system is drawn for once the clock is set to one', async () => { + const note = (): string => (fixture.componentInstance as unknown as { hudNote: () => string }).hudNote(); + TestBed.inject(TimeStore).setDate(new Date('2020-12-21T18:00Z')); + navigationStore.selectStar(SUN.id); + await flushAsync(); + await advanceFrames(engine, 2.5); + // The great conjunction, to the minute: not "now", and not a date the reader has to find. + expect(note()).toMatch(/ to 2020-12-21 18:00 UTC\.$/); }); it('performs the floating-origin recenter: the camera lands close to the AU-space origin, not out at parsec-scale coordinates', async () => { diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index d8630d1..54a1b1e 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -1763,7 +1763,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // Where the orbits come from, and for the Sun how far from the present they hold: each // body's card names its own source and epoch. const source = this.bodies.some((body) => body.systemStarId === star.id) ? 'JPL mean elements, the planets’ fit for 3000 BC to AD 3000, and the SBDB’s osculating ones for Ceres, Eris, Haumea and Makemake,' : 'published elements'; - this.hudNote.set(`Orbits propagated from ${source} to ${this.time.atNow() ? 'the current date' : 'the date on the clock'}.`); + // Named to the minute, in UTC like the date field: a jump to 18:00 on a given day is a + // question about that hour, and the note is where the answer says which sky it is. + const drawnFor = `${this.time.date().toISOString().slice(0, 16).replace('T', ' ')} UTC`; + this.hudNote.set(`Orbits propagated from ${source} to ${this.time.atNow() ? 'now, ' : ''}${drawnFor}.`); this.hudRange.set( formatAu( this.engine.visibleHalfHeight( diff --git a/src/app/features/hud/hud-dock.component.spec.ts b/src/app/features/hud/hud-dock.component.spec.ts index 1438892..7b3f361 100644 --- a/src/app/features/hud/hud-dock.component.spec.ts +++ b/src/app/features/hud/hud-dock.component.spec.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DataLoaderService } from '../../core/data/data-loader.service'; import { BookmarksStore } from '../../shared/state/bookmarks.store'; +import { TimeStore } from '../../shared/state/time.store'; import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent } from './hud-dock.component'; class EmptyDataLoaderService { @@ -141,7 +142,7 @@ describe('HudDockComponent', () => { fixture.componentRef.setInput('defaultTab', 'display'); fixture.detectChanges(); const pressed = [...host().querySelectorAll('[aria-pressed]')].map((b) => `${b.textContent?.trim()}=${b.getAttribute('aria-pressed')}`); - expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true', 'Jump links=false', 'Plan view=false']); + expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true', 'Jump links=false', 'Plan view=false', 'Backwards=false']); }); it('says how to keep a place, rather than showing an empty list', () => { @@ -366,4 +367,69 @@ describe('HudDockComponent', () => { expect(host().querySelector('#route-to')!.value).toBe('Sirius'); expect(host().querySelector('#route-range')!.value).toBe('6'); }); + + describe('clock', () => { + let time: TimeStore; + + beforeEach(() => { + time = TestBed.inject(TimeStore); + fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY); + fixture.componentRef.setInput('defaultTab', 'display'); + fixture.detectChanges(); + }); + + function button(name: string): HTMLButtonElement { + return [...host().querySelectorAll('#dock-panel-display button')].find((b) => b.textContent?.trim() === name)!; + } + + function radio(name: string): HTMLInputElement { + return [...host().querySelectorAll('#dock-panel-display label')].find((l) => l.textContent?.trim() === name)!.querySelector('input')!; + } + + it('runs the same rates backwards, and keeps the direction when the rate changes', () => { + button('Backwards').click(); + fixture.detectChanges(); + expect(time.rate()).toBe(-1); + expect(button('Backwards').getAttribute('aria-pressed')).toBe('true'); + // Still real time: the direction is not a fifth rate. + expect(radio('Real time').checked).toBe(true); + + radio('1 d/s').click(); + fixture.detectChanges(); + expect(time.rate()).toBe(-86_400); + expect(radio('1 d/s').checked).toBe(true); + + button('Backwards').click(); + fixture.detectChanges(); + expect(time.rate()).toBe(86_400); + expect(button('Backwards').getAttribute('aria-pressed')).toBe('false'); + }); + + it('opens the date field on the clock’s date, named and held to the window the elements hold for', () => { + const field = host().querySelector('#clock-date')!; + + expect(field.type).toBe('datetime-local'); + expect(field.value).toBe(time.date().toISOString().slice(0, 16)); + expect(host().querySelector('label[for="clock-date"]')?.textContent?.trim()).toBe('Date (UTC)'); + expect(field.min).toBe('0001-01-01T00:00'); + expect(field.max).toBe('3000-01-01T00:00'); + expect(host().querySelector(`#${field.getAttribute('aria-describedby')}`)?.textContent).toContain('AD 1 to AD 3000'); + }); + + it('jumps the clock to the date submitted, read as UTC', () => { + const field = host().querySelector('#clock-date')!; + field.value = '2020-12-21T18:00'; + button('Go').click(); + fixture.detectChanges(); + + expect(time.date().toISOString().slice(0, 16)).toBe('2020-12-21T18:00'); + expect(time.atNow()).toBe(false); + + // Back to now puts the field back on the present too, not on the date left behind. + button('Back to now').click(); + fixture.detectChanges(); + expect(time.atNow()).toBe(true); + expect(host().querySelector('#clock-date')!.value).toBe(time.date().toISOString().slice(0, 16)); + }); + }); }); diff --git a/src/app/features/hud/hud-dock.component.ts b/src/app/features/hud/hud-dock.component.ts index fc00fcc..e8858f3 100644 --- a/src/app/features/hud/hud-dock.component.ts +++ b/src/app/features/hud/hud-dock.component.ts @@ -13,7 +13,7 @@ import { } from '@angular/core'; import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store'; -import { TIME_RATES, TimeStore } from '../../shared/state/time.store'; +import { CLOCK_WINDOW, TIME_RATES, TimeStore } from '../../shared/state/time.store'; import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component'; import { SearchComponent } from '../search/search.component'; import { @@ -303,11 +303,13 @@ function isWideViewport(): boolean { role="radiogroup" aria-label="Clock rate" > + @for (rate of timeRates; track rate.secondsPerSecond) { } + @if (!time.atNow()) { } + +
+ + + +

+ AD 1 to AD 3000, where the planets’ elements hold. +

+
} } @@ -458,12 +505,20 @@ export class HudDockComponent implements OnInit { readonly bookmarks = inject(BookmarksStore); readonly time = inject(TimeStore); readonly timeRates = TIME_RATES; + readonly clockWindow = CLOCK_WINDOW; + protected readonly Math = Math; + /** + * What the date field holds when the panel opens: the clock's date at that moment. Not bound to + * the running clock, which would rewrite the field under the reader's typing on every render. + */ + readonly dateField = signal(''); private readonly search = viewChild(SearchComponent); private readonly host = inject>(ElementRef); ngOnInit(): void { this.activeTab.set(isWideViewport() ? this.defaultTab() : null); + this.fillDateField(); } tabLabel(tab: DockTab): string { @@ -476,6 +531,27 @@ export class HudDockComponent implements OnInit { toggleTab(tab: DockTab): void { this.activeTab.set(this.activeTab() === tab ? null : tab); + this.fillDateField(); + } + + /** The field's value is read as UTC, which is what the strip and the note print dates in. */ + goToDate(event: SubmitEvent): void { + event.preventDefault(); + const field = (event.target as HTMLFormElement).elements.namedItem('date') as HTMLInputElement; + if (this.time.setDate(new Date(`${field.value}Z`))) { + // The field now says what the signal behind it does, so a later reset that fills it with + // the present is a change the binding writes back, not one it drops as the same value. + this.dateField.set(field.value); + } + } + + backToNow(): void { + this.time.reset(); + this.fillDateField(); + } + + private fillDateField(): void { + this.dateField.set(this.time.date().toISOString().slice(0, 16)); } toggleLayer(key: keyof HudDisplay): void { diff --git a/src/app/shared/state/time.store.spec.ts b/src/app/shared/state/time.store.spec.ts index 3435338..762a47f 100644 --- a/src/app/shared/state/time.store.spec.ts +++ b/src/app/shared/state/time.store.spec.ts @@ -71,4 +71,32 @@ describe('TimeStore', () => { // Now, not the moment the store was built: five seconds of wall clock have passed. expect(time.date().toISOString()).toBe(new Date(START.getTime() + 5000).toISOString()); }); + + it('jumps to a date and carries on from it at the rate it was running at', () => { + vi.advanceTimersByTime(5000); // five seconds before the jump, which it must not add on + expect(time.setDate(new Date('2020-12-21T18:00Z'))).toBe(true); + // Away from now although still at real time: the rate never changed, the date did. + expect(time.atNow()).toBe(false); + vi.advanceTimersByTime(1000); + expect(time.date().toISOString()).toBe('2020-12-21T18:00:01.000Z'); + + time.setRate(3600); + vi.advanceTimersByTime(1000); + expect(time.date().toISOString()).toBe('2020-12-21T19:00:01.000Z'); + }); + + it('refuses a date the planets’ elements were never fitted for, and stays where it was', () => { + const before = time.date().toISOString(); + + expect(time.setDate(new Date('3000-01-01T00:01Z'))).toBe(false); + expect(time.setDate(new Date(Date.UTC(-100, 0, 1)))).toBe(false); // 101 BC + expect(time.setDate(new Date('not a date'))).toBe(false); + expect(time.date().toISOString()).toBe(before); + expect(time.atNow()).toBe(true); + + // Both ends are in: the first day a date input can hold, and the end of Standish's fit. + expect(time.setDate(new Date('0001-01-01T00:00Z'))).toBe(true); + expect(time.date().toISOString()).toBe('0001-01-01T00:00:00.000Z'); + expect(time.setDate(new Date('3000-01-01T00:00Z'))).toBe(true); + }); }); diff --git a/src/app/shared/state/time.store.ts b/src/app/shared/state/time.store.ts index 7c783df..ca66ffc 100644 --- a/src/app/shared/state/time.store.ts +++ b/src/app/shared/state/time.store.ts @@ -25,6 +25,22 @@ export const TIME_RATES = [ const MS_PER_DAY = 86_400_000; const JULIAN_DATE_AT_EPOCH = 2440587.5; +/** + * The dates the clock can be set to, as `datetime-local` values read as UTC. + * + * The end is where Standish's Table 2, the mean elements that carry the planets, stops being + * fitted: it covers 3000 BC to AD 3000, and every planet was within 0.29 degrees of Horizons at + * each date measured out to 3000. The start is not the fit's but the date input's, which cannot + * go before 0001-01-01. Both are proleptic Gregorian, as a `Date` is, so before 1582 they run + * ahead of the Julian-calendar dates history gives: two days at AD 1, ten by 1582. The moons and + * dwarf planets hold for far less of it: Phobos is 11 degrees out by 2100, Ceres 11.6 by 2200. + */ +export const CLOCK_WINDOW = { min: '0001-01-01T00:00', max: '3000-01-01T00:00' } as const; +const WINDOW_MS = { + min: Date.parse(`${CLOCK_WINDOW.min}Z`), + max: Date.parse(`${CLOCK_WINDOW.max}Z`), +}; + /** * The date the map is drawn for. * @@ -33,7 +49,9 @@ const JULIAN_DATE_AT_EPOCH = 2440587.5; * watching. The rate *is* a signal, since a reader sets it and the controls read it back. * * Changing the rate re-anchors instead of rewinding: the date carries on from where it had got - * to, so speeding up and slowing down never jumps the sky. + * to, so speeding up and slowing down never jumps the sky. A negative rate runs the same clock + * backwards: every orbit and every rotation is a function of the date, so going back is the same + * sum with the sign turned. */ @Injectable({ providedIn: 'root' }) export class TimeStore { @@ -72,6 +90,23 @@ export class TimeStore { } } + /** + * Jumps the clock to a date, from which it carries on at whatever rate it was running at. + * Refuses one outside {@link CLOCK_WINDOW}, rather than draw planets where elements that were + * never fitted there put them. + */ + setDate(date: Date): boolean { + const ms = date.getTime(); + // Written so that NaN, an unparsable field, fails it too. + if (!(ms >= WINDOW_MS.min && ms <= WINDOW_MS.max)) { + return false; + } + this.anchorJd = dateToJulianDate(date); + this.anchorWallMs = Date.now(); + this.atNow.set(false); + return true; + } + /** Back to now, at real time — the state the map opens in. */ reset(): void { this.anchorJd = dateToJulianDate();