Let the reader set the clock to a date, and run it backwards
The clock from #33 could only run forwards from now. The Display panel's clock now has a Date (UTC) field, a native datetime-local in a form, so Enter submits it and the browser holds it to its min and max. It jumps the clock to that date, and the clock carries on from there at the rate it was running at. A Backwards toggle (aria-pressed) runs the same four rates the other way. The radios still pick the rate's size and keep the direction when it changes. The window is AD 1 to AD 3000. The end is where Standish's Table 2 stops being fitted (3000 BC to AD 3000; every planet within 0.29 degrees of Horizons at each date measured out to 3000). The start is the date input's own floor. TimeStore.setDate refuses anything outside it, and NaN, and leaves the clock where it was. The field is read as UTC. Its dates are proleptic Gregorian, as a Date is, so before 1582 they run up to ten days ahead of the Julian-calendar dates history gives. The window is written beside CLOCK_WINDOW, with the moons' shorter reach (Phobos 11 degrees out by 2100). The system note now names the date it is drawn for, to the minute: "... to 2020-12-21 18:00 UTC.", or "to now, <date> UTC." at the present. Measured in the app (port 4311, keyboard only: fill, Enter): - Set to 2020-12-21 18:00 UTC, Jupiter and Saturn seen from Earth's drawn position are 0.113 degrees apart. Horizons gives 0.102 geocentric (geometric 0.1017, astrometric 0.1018). Distances: 5.9267 and 10.8296 AU against Horizons' 5.9258 and 10.8270. - 3001-06-01 is refused by the form (validity false) and the clock does not move. - Backwards at 1 d/s: -2.011 days in about 2 s. - The field's accessible name is "Date (UTC)" and its description is the window. Its colour-scheme is dark, so the picker icon shows on the HUD. - Back to now puts the field back on the present too. Unit suite 799 -> 805: two tests for the store, three for the dock, one for the scene note. Eleven guarded mutants; each changed its file and made its named test fail. Among them: the window check dropped, the wall clock not re-anchored on a jump, the radio dropping the direction, the field read as local time, and the note not naming the date. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import { DeepSkyRecord } from '../../shared/models/deepsky.model';
|
|||||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||||
import { StarRecord } from '../../shared/models/star.model';
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||||
|
import { TimeStore } from '../../shared/state/time.store';
|
||||||
import { LinkBudget } from '../../shared/astro/jump-links';
|
import { LinkBudget } from '../../shared/astro/jump-links';
|
||||||
import { HudDisplay } from '../hud/hud-dock.component';
|
import { HudDisplay } from '../hud/hud-dock.component';
|
||||||
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
|
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
|
||||||
@@ -836,12 +837,22 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
|||||||
navigationStore.selectStar(SUN.id);
|
navigationStore.selectStar(SUN.id);
|
||||||
await flushAsync();
|
await flushAsync();
|
||||||
await advanceFrames(engine, 2.5);
|
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);
|
navigationStore.selectStar(ALPHA_CENTAURI.id);
|
||||||
await flushAsync();
|
await flushAsync();
|
||||||
await advanceFrames(engine, 5);
|
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 () => {
|
it('performs the floating-origin recenter: the camera lands close to the AU-space origin, not out at parsec-scale coordinates', async () => {
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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';
|
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(
|
this.hudRange.set(
|
||||||
formatAu(
|
formatAu(
|
||||||
this.engine.visibleHalfHeight(
|
this.engine.visibleHalfHeight(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||||
import { BookmarksStore } from '../../shared/state/bookmarks.store';
|
import { BookmarksStore } from '../../shared/state/bookmarks.store';
|
||||||
|
import { TimeStore } from '../../shared/state/time.store';
|
||||||
import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent } from './hud-dock.component';
|
import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent } from './hud-dock.component';
|
||||||
|
|
||||||
class EmptyDataLoaderService {
|
class EmptyDataLoaderService {
|
||||||
@@ -141,7 +142,7 @@ describe('HudDockComponent', () => {
|
|||||||
fixture.componentRef.setInput('defaultTab', 'display');
|
fixture.componentRef.setInput('defaultTab', 'display');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const pressed = [...host().querySelectorAll('[aria-pressed]')].map((b) => `${b.textContent?.trim()}=${b.getAttribute('aria-pressed')}`);
|
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', () => {
|
it('says how to keep a place, rather than showing an empty list', () => {
|
||||||
@@ -366,4 +367,69 @@ describe('HudDockComponent', () => {
|
|||||||
expect(host().querySelector<HTMLInputElement>('#route-to')!.value).toBe('Sirius');
|
expect(host().querySelector<HTMLInputElement>('#route-to')!.value).toBe('Sirius');
|
||||||
expect(host().querySelector<HTMLInputElement>('#route-range')!.value).toBe('6');
|
expect(host().querySelector<HTMLInputElement>('#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<HTMLButtonElement>('#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<HTMLInputElement>('#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<HTMLInputElement>('#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<HTMLInputElement>('#clock-date')!.value).toBe(time.date().toISOString().slice(0, 16));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store';
|
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 { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
|
||||||
import { SearchComponent } from '../search/search.component';
|
import { SearchComponent } from '../search/search.component';
|
||||||
import {
|
import {
|
||||||
@@ -303,11 +303,13 @@ function isWideViewport(): boolean {
|
|||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label="Clock rate"
|
aria-label="Clock rate"
|
||||||
>
|
>
|
||||||
|
<!-- A rate is picked by its size and the toggle after the radios says which way it
|
||||||
|
runs, so a month a second backwards is the same radio as forwards. -->
|
||||||
@for (rate of timeRates; track rate.secondsPerSecond) {
|
@for (rate of timeRates; track rate.secondsPerSecond) {
|
||||||
<label
|
<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="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]="
|
[class]="
|
||||||
time.rate() === rate.secondsPerSecond
|
Math.abs(time.rate()) === rate.secondsPerSecond
|
||||||
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
||||||
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
||||||
"
|
"
|
||||||
@@ -317,22 +319,67 @@ function isWideViewport(): boolean {
|
|||||||
name="clock-rate"
|
name="clock-rate"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
[value]="rate.secondsPerSecond"
|
[value]="rate.secondsPerSecond"
|
||||||
[checked]="time.rate() === rate.secondsPerSecond"
|
[checked]="Math.abs(time.rate()) === rate.secondsPerSecond"
|
||||||
(change)="time.setRate(rate.secondsPerSecond)"
|
(change)="time.setRate(Math.sign(time.rate()) * rate.secondsPerSecond)"
|
||||||
/>
|
/>
|
||||||
{{ rate.label }}
|
{{ rate.label }}
|
||||||
</label>
|
</label>
|
||||||
}
|
}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[attr.aria-pressed]="time.rate() < 0"
|
||||||
|
(click)="time.setRate(-time.rate())"
|
||||||
|
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]="
|
||||||
|
time.rate() < 0
|
||||||
|
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
||||||
|
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-1.5 w-1.5 border border-current"
|
||||||
|
[class.bg-current]="time.rate() < 0"
|
||||||
|
></span>
|
||||||
|
Backwards
|
||||||
|
</button>
|
||||||
@if (!time.atNow()) {
|
@if (!time.atNow()) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="time.reset()"
|
(click)="backToNow()"
|
||||||
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"
|
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
|
Back to now
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- A form, so Enter in the field goes there and the browser holds the field to its
|
||||||
|
min and max before anything is submitted. Submitted rather than applied on each
|
||||||
|
change: Chrome reports a year typed digit by digit as 0002, 0020, 0202 and 2020,
|
||||||
|
and the sky would jump through every one. -->
|
||||||
|
<form class="mt-2 flex flex-wrap items-center gap-2" (submit)="goToDate($event)">
|
||||||
|
<label for="clock-date" class="type-label text-muted">Date (UTC)</label>
|
||||||
|
<input
|
||||||
|
id="clock-date"
|
||||||
|
name="date"
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
[min]="clockWindow.min"
|
||||||
|
[max]="clockWindow.max"
|
||||||
|
[value]="dateField()"
|
||||||
|
aria-describedby="clock-date-window"
|
||||||
|
class="hud-surface px-2.5 py-1 text-sm text-text tabular-nums caret-accent scheme-dark focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Go
|
||||||
|
</button>
|
||||||
|
<p id="clock-date-window" class="w-full text-[10px] text-muted">
|
||||||
|
AD 1 to AD 3000, where the planets’ elements hold.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,12 +505,20 @@ export class HudDockComponent implements OnInit {
|
|||||||
readonly bookmarks = inject(BookmarksStore);
|
readonly bookmarks = inject(BookmarksStore);
|
||||||
readonly time = inject(TimeStore);
|
readonly time = inject(TimeStore);
|
||||||
readonly timeRates = TIME_RATES;
|
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 search = viewChild(SearchComponent);
|
||||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.activeTab.set(isWideViewport() ? this.defaultTab() : null);
|
this.activeTab.set(isWideViewport() ? this.defaultTab() : null);
|
||||||
|
this.fillDateField();
|
||||||
}
|
}
|
||||||
|
|
||||||
tabLabel(tab: DockTab): string {
|
tabLabel(tab: DockTab): string {
|
||||||
@@ -476,6 +531,27 @@ export class HudDockComponent implements OnInit {
|
|||||||
|
|
||||||
toggleTab(tab: DockTab): void {
|
toggleTab(tab: DockTab): void {
|
||||||
this.activeTab.set(this.activeTab() === tab ? null : tab);
|
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 {
|
toggleLayer(key: keyof HudDisplay): void {
|
||||||
|
|||||||
@@ -71,4 +71,32 @@ describe('TimeStore', () => {
|
|||||||
// Now, not the moment the store was built: five seconds of wall clock have passed.
|
// 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());
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ export const TIME_RATES = [
|
|||||||
const MS_PER_DAY = 86_400_000;
|
const MS_PER_DAY = 86_400_000;
|
||||||
const JULIAN_DATE_AT_EPOCH = 2440587.5;
|
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.
|
* 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.
|
* 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
|
* 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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class TimeStore {
|
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. */
|
/** Back to now, at real time — the state the map opens in. */
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.anchorJd = dateToJulianDate();
|
this.anchorJd = dateToJulianDate();
|
||||||
|
|||||||
Reference in New Issue
Block a user