Give the map a clock, so the sky it computes can be watched
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<ScaleBar | null>(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<HudDisplay>(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;
|
||||
|
||||
@@ -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 {
|
||||
</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>
|
||||
<!-- 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 class="mt-2 flex flex-wrap items-center gap-2" role="radiogroup" aria-label="Clock rate">
|
||||
@for (rate of timeRates; track rate.secondsPerSecond) {
|
||||
<label
|
||||
class="type-label cursor-pointer border px-3 py-1.5 transition-colors has-[:focus-visible]:outline has-[:focus-visible]:outline-1 has-[:focus-visible]:-outline-offset-1 has-[:focus-visible]:outline-accent"
|
||||
[class]="time.rate() === rate.secondsPerSecond ? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18' : 'border-border/60 text-muted hover:border-border hover:text-text'"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="clock-rate"
|
||||
class="sr-only"
|
||||
[value]="rate.secondsPerSecond"
|
||||
[checked]="time.rate() === rate.secondsPerSecond"
|
||||
(change)="time.setRate(rate.secondsPerSecond)"
|
||||
/>
|
||||
{{ rate.label }}
|
||||
</label>
|
||||
}
|
||||
@if (time.rate() !== 1) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="time.reset()"
|
||||
class="type-label border border-border/60 px-3 py-1.5 text-muted transition-colors hover:border-accent/70 hover:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
>
|
||||
Back to now
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -221,8 +255,16 @@ function isWideViewport(): boolean {
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<!-- Only while the clock is running faster than the world: at real time the date is
|
||||
today's, which the reader's own machine already says. -->
|
||||
@if (date()) {
|
||||
<p class="ml-auto flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4" data-testid="hud-date">
|
||||
<span class="type-label text-muted">Date</span>
|
||||
<span class="text-sm text-accent tabular-nums">{{ date() }}</span>
|
||||
</p>
|
||||
}
|
||||
@if (range()) {
|
||||
<p class="ml-auto flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4">
|
||||
<p class="flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4" [class.ml-auto]="!date()">
|
||||
<span class="type-label text-muted">Range</span>
|
||||
<span class="text-sm text-accent tabular-nums">{{ range() }}</span>
|
||||
</p>
|
||||
@@ -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<HudDisplay | null>(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<DockTab | null>(null);
|
||||
|
||||
readonly bookmarks = inject(BookmarksStore);
|
||||
readonly time = inject(TimeStore);
|
||||
readonly timeRates = TIME_RATES;
|
||||
|
||||
private readonly search = viewChild(SearchComponent);
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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<number>(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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user