From 4b276e44a5bc69761f7c857fa43467601a8a40ef Mon Sep 17 00:00:00 2001 From: Senrokai Date: Tue, 22 Sep 2026 10:59:28 +0200 Subject: [PATCH] Give the map a clock, so the sky it computes can be watched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orbits and the rotations are both functions of a date, and the only date the map ever asked for was this instant. So a view built on propagated ephemerides showed a still picture: Earth turns 15 degrees an hour and takes a year to go round, and a reader watching for a minute saw nothing move at all. `TimeStore` is that date, at a rate the reader sets: real time, an hour a second, a day a second, a month a second. It is read once a frame rather than held in a signal — it changes continuously, and a signal changing sixty times a second would ask the whole HUD to re-render for a number nothing is watching. Changing the rate re-anchors rather than rewinding, so speeding up and slowing down never jumps the sky, and "Back to now" returns to the world's own time. Measured in the app, three seconds of watching in the Sun's system: | rate | sky elapsed | Earth turned | Jupiter moved | |---|---|---|---| | real time | 0 | 0 | 0 | | 1 h/s | 3.0 h | 45.12 deg | 0.0009 AU | | 1 d/s | 3.0 d | (three full turns) | 0.0222 AU | 45.12 degrees in three hours is 15.04 an hour, which is Earth's own sidereal rate, and Jupiter's 0.0222 AU in three days is its own orbital speed. The rates are radio buttons, not toggles: they are one of four, and the native control carries that to a screen reader and to the arrow keys with no script. The date joins the strip only while the clock is running faster than the world, since at real time it is today's, which the reader's machine already says. Co-Authored-By: Claude Opus 5 (1M context) --- .../galaxy-system-scene.component.ts | 14 +++- src/app/features/hud/hud-dock.component.ts | 48 +++++++++++- src/app/shared/state/time.store.spec.ts | 62 ++++++++++++++++ src/app/shared/state/time.store.ts | 73 +++++++++++++++++++ 4 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 src/app/shared/state/time.store.spec.ts create mode 100644 src/app/shared/state/time.store.ts 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); + } +}