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 c0238eb..47d85fb 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -3,7 +3,6 @@ import { Router } from '@angular/router'; import * as THREE from 'three/webgpu'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; -import { dateToJulianDate } from '../../shared/astro/constants'; import { GALACTIC_BASIS_EQUATORIAL, MILKY_WAY_ARMS, @@ -22,6 +21,7 @@ import { isDesignation } from '../../shared/models/star-catalog'; import { StarRecord } from '../../shared/models/star.model'; import { Bookmark } from '../../shared/state/bookmarks.store'; import { NavigationStore, ViewLevel } from '../../shared/state/navigation.store'; +import { TimeStore } from '../../shared/state/time.store'; import { CameraRigController } from './camera-rig-controller'; import { DeepSkyRenderer } from './deep-sky-renderer'; import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane'; @@ -304,6 +304,7 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector [readouts]="hudReadouts()" [note]="hudNote()" [range]="hudRange()" + [date]="hudDate()" [display]="display()" [routing]="true" [routeResult]="routeResult()" @@ -350,6 +351,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { readonly hudNote = signal(''); readonly hudRange = signal(''); readonly hudScale = signal(null); + /** The date the sky is drawn for — worth printing once the clock is no longer the world's. */ + readonly hudDate = signal(''); /** Which layers are drawn, as toggled from the dock. Applied by `applyDisplay`. */ readonly display = signal(DEFAULT_HUD_DISPLAY); @@ -470,7 +473,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { private readonly engine: EngineService, private readonly dataLoader: DataLoaderService, private readonly router: Router, - readonly navigationStore: NavigationStore + readonly navigationStore: NavigationStore, + readonly time: TimeStore ) { effect(() => { const selectedStarId = this.navigationStore.selectedStarId(); @@ -701,7 +705,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { } if (this.systemGroup.visible) { - this.systemRenderer?.update(dateToJulianDate()); + this.systemRenderer?.update(this.time.julianDate()); this.keepMarkersLegible(camera); } this.updateSelectionMark(camera); @@ -1473,6 +1477,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { private updateHud(camera: SceneCamera): void { const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId); + // On the label cadence rather than per frame: at a month a second the date changes faster + // than anyone can read it, and the HUD does not need to re-render sixty times a second to + // say so. + this.hudDate.set(this.time.rate() === 1 ? '' : this.time.date().toISOString().slice(0, 10)); if (this.systemGroup.visible && star) { const planetCount = this.bodies.filter((body) => body.systemStarId === star.id && !body.parentBodyId).length + this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id).length; diff --git a/src/app/features/hud/hud-dock.component.ts b/src/app/features/hud/hud-dock.component.ts index 3a0ad5d..8825af2 100644 --- a/src/app/features/hud/hud-dock.component.ts +++ b/src/app/features/hud/hud-dock.component.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, computed, ElementRef, HostListener, inject, input, OnInit, output, signal, viewChild } from '@angular/core'; import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store'; +import { TIME_RATES, TimeStore } from '../../shared/state/time.store'; import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component'; import { SearchComponent } from '../search/search.component'; import { RouteRequest, RouteResult, RoutesPanelComponent, RouteStarOption } from './routes-panel.component'; @@ -180,6 +181,39 @@ function isWideViewport(): boolean { } + + +

Clock

+ +
+ @for (rate of timeRates; track rate.secondsPerSecond) { + + } + @if (time.rate() !== 1) { + + } +
} } @@ -221,8 +255,16 @@ function isWideViewport(): boolean { } + + @if (date()) { +

+ Date + {{ date() }} +

+ } @if (range()) { -

+

Range {{ range() }}

@@ -241,6 +283,8 @@ export class HudDockComponent implements OnInit { readonly note = input(''); /** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */ readonly range = input(''); + /** The date the sky is drawn for; empty while the clock runs at real time. */ + readonly date = input(''); /** Layer state; `null` means the surface has no layers to toggle and no Display tab. */ readonly display = input(null); /** Which panel is open on a wide viewport when the dock mounts. */ @@ -277,6 +321,8 @@ export class HudDockComponent implements OnInit { readonly activeTab = signal(null); readonly bookmarks = inject(BookmarksStore); + readonly time = inject(TimeStore); + readonly timeRates = TIME_RATES; private readonly search = viewChild(SearchComponent); private readonly host = inject>(ElementRef); diff --git a/src/app/shared/state/time.store.spec.ts b/src/app/shared/state/time.store.spec.ts new file mode 100644 index 0000000..cf1ec86 --- /dev/null +++ b/src/app/shared/state/time.store.spec.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TimeStore } from './time.store'; + +const MS_PER_DAY = 86_400_000; +const START = new Date('2026-09-22T12:00:00Z'); + +describe('TimeStore', () => { + let time: TimeStore; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(START); + time = new TimeStore(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('keeps the world’s own time until asked otherwise', () => { + const opened = time.julianDate(); + vi.advanceTimersByTime(10_000); + + expect(time.julianDate() - opened).toBeCloseTo(10_000 / MS_PER_DAY, 9); + expect(time.date().toISOString()).toBe('2026-09-22T12:00:10.000Z'); + }); + + it('runs the sky faster without moving where it starts from', () => { + const opened = time.julianDate(); + time.setRate(3600); + vi.advanceTimersByTime(1000); + + // A second of watching is an hour of sky, and the date did not jump when the rate changed. + expect(time.julianDate() - opened).toBeCloseTo(1 / 24, 9); + }); + + it('carries on from where it had got to when the rate changes again', () => { + time.setRate(86_400); + vi.advanceTimersByTime(2000); // two days of sky + const afterTwoDays = time.julianDate(); + + time.setRate(1); + vi.advanceTimersByTime(1000); + + // Slowing down keeps the two days: it does not rewind to the wall clock. + expect(time.julianDate() - afterTwoDays).toBeCloseTo(1000 / MS_PER_DAY, 9); + expect(time.julianDate() - afterTwoDays).toBeLessThan(1 / 24); + }); + + it('comes back to now, at real time', () => { + time.setRate(2_629_800); + vi.advanceTimersByTime(5000); // months away + expect(time.date().getUTCFullYear()).toBeGreaterThan(START.getUTCFullYear()); + + time.reset(); + + expect(time.rate()).toBe(1); + // 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()); + }); +}); diff --git a/src/app/shared/state/time.store.ts b/src/app/shared/state/time.store.ts new file mode 100644 index 0000000..d911b2a --- /dev/null +++ b/src/app/shared/state/time.store.ts @@ -0,0 +1,73 @@ +import { Injectable, signal } from '@angular/core'; + +import { dateToJulianDate } from '../astro/constants'; + +/** + * How fast the map's clock runs, in seconds of sky per second of wall clock. + * + * The map is built on propagated orbits and published rotation periods, both of which are + * functions of a date — so the only thing standing between it and a working orrery is the number + * on this list. At real time nothing appears to move: Earth turns 15 degrees an hour and takes a + * year to go round, and a reader watching for a minute sees a still picture. + * + * An hour a second is the rate at which rotation reads — Jupiter turns once every ten seconds of + * watching. A day a second is the rate at which the inner planets read. A month a second carries + * the outer ones, at which point the inner four are a blur, which is honest: that is what the + * solar system does. + */ +export const TIME_RATES = [ + { label: 'Real time', secondsPerSecond: 1 }, + { label: '1 h/s', secondsPerSecond: 3600 }, + { label: '1 d/s', secondsPerSecond: 86_400 }, + { label: '1 mo/s', secondsPerSecond: 2_629_800 } +] as const; + +const MS_PER_DAY = 86_400_000; +const JULIAN_DATE_AT_EPOCH = 2440587.5; + +/** + * The date the map is drawn for. + * + * Read every frame rather than held in a signal: it changes continuously, and a signal that + * changed sixty times a second would ask the whole HUD to re-render for a number nothing is + * 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. + */ +@Injectable({ providedIn: 'root' }) +export class TimeStore { + readonly rate = signal(TIME_RATES[0].secondsPerSecond); + + private anchorJd = dateToJulianDate(); + private anchorWallMs = Date.now(); + + /** Julian date for this instant, at the rate the reader chose. */ + julianDate(): number { + return this.anchorJd + ((Date.now() - this.anchorWallMs) * this.rate()) / MS_PER_DAY; + } + + /** + * The same instant as a date, for anything that prints it. + * + * Rounded to the millisecond, which is all a `Date` holds: a Julian date near 2 461 000 has + * about a twentieth of a millisecond of resolution left in a double, and `new Date` truncates + * what is left rather than rounding it, so ten seconds came back as 9.999. + */ + date(): Date { + return new Date(Math.round((this.julianDate() - JULIAN_DATE_AT_EPOCH) * MS_PER_DAY)); + } + + setRate(secondsPerSecond: number): void { + this.anchorJd = this.julianDate(); + this.anchorWallMs = Date.now(); + this.rate.set(secondsPerSecond); + } + + /** Back to now, at real time — the state the map opens in. */ + reset(): void { + this.anchorJd = dateToJulianDate(); + this.anchorWallMs = Date.now(); + this.rate.set(TIME_RATES[0].secondsPerSecond); + } +}