Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ SceneryStack **first-person planetarium** for the night sky. Observer location,
**Gotchas**

- **Ctrl-drag** and **Ctrl+arrow keys** advance **sidereal time** (stars move 1:1 with gesture); civil time advances ~1/1.0027 as fast.
- Civil time is bounded by `CIVIL_TIME_MS_RANGE` (derived from `CIVIL_YEAR_RANGE`, 1900–2100). Route every write that could leave that span through `setCivilTimeMs()`; the `date` query parameter rejects out-of-range epochs rather than clamping them.
- `epochPresetProperty` only flips to `CUSTOM` once civil time is a full minute off the preset — the timer starts playing, so a tighter test would mark it CUSTOM on the first frame and the combo could never show a preset.
- Reset All restores model Properties but **not** preference-backed overlays (`showStarLabels`, `showConstellations`, `showPlanetLabels`, `deepStarCatalog`).
- Planet positions use `astronomy-engine` only through `PlanetEphemeris.ts`; equatorial↔horizontal transforms are intentionally hand-rolled (see [doc/astronomy-engine.md](doc/astronomy-engine.md)).
- Default sky: Boulder (40° N, 105° W), 2024-06-21 18:00 UTC, look south 30° alt, 140° FOV.
Expand All @@ -48,7 +50,9 @@ Follows the shared [OpenPhysics accessibility convention](https://github.com/Ope

## Compliance carve-outs

- **Hardcoded colors:** `#ffffff` pin stroke in `ObserverLocationNode.ts` — fixed white ring for map legibility on both land and ocean fills; not a profile theme token.
- **Hardcoded colors:** `#ffffff` pin stroke in `ObserverLocationNode.ts` — fixed white ring for map legibility on both land and ocean fills; not a profile theme token. Invisible hit targets use `Color.TRANSPARENT`, not a literal `rgba(0,0,0,0)`.

**Color pairing gotcha:** `LIGHT_SURFACE_TEXT_FILL` (`controlSurfaceText`, near-black) is only for text on the *white* control surfaces — combo items, flat-button labels, editable field values. Anything drawn on the dark `panelBackground` fill takes `ZenithColors.textColorProperty`. Mixing these up yields ~1:1 contrast and is invisible rather than merely ugly (see the `ObjectNameSearch` result rows).

## Testing

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ Vite 8, TypeScript 7, and Biome 2.
- Sun, Moon, and Mercury–Neptune via `astronomy-engine` ephemerides (angularly correct Sun/Moon discs; optional true-scale planets)
- Observer latitude/longitude, civil UTC time, and derived local sidereal time with play / pause / speed
- English, Spanish, and French localization via `StringManager`
- Deep-link startup via query params (`lat`, `lon`, `date`, `fov`, `magLimit`)
- Deep-link startup via query params (`lat`, `lon`, `date` — 1900–2100, `fov`, `magLimit`)
- Optional "Use my location": browser geolocation, falling back to a third-party IP lookup
(`get.geojs.io`, then `ipapi.co`) when the browser cannot answer. An explicit permission
denial is honoured with no fallback, and the button is the only thing that makes either
request — nothing is sent at startup, and the rest of the sim runs fully offline.
- Default (night-sky) and projector color profiles
- Progressive Web App (installable, offline-capable)
- Git hooks for Biome pre-commit checks
Expand Down
11 changes: 8 additions & 3 deletions doc/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ works across many longitudes):
- June / December solstices

Year / month / day / hour controls jump to any UTC civil moment in range
(1900–2100).
(1900–2100). That range bounds civil time everywhere: playback stops at either
end rather than running past it, and a `date` deep link outside it falls back to
the default epoch instead of being silently clamped to the boundary.

### Default sky (reset / first load)

Expand Down Expand Up @@ -101,7 +103,10 @@ south at a modest altitude — a natural outdoor “looking up” pose.

- **Object name search** (top-center) — type-ahead over **47 named entries** (38 curated bright stars +
9 solar-system bodies). Enter selects and enables tracking.
- **"Use my location"** — browser geolocation with IP fallback.
- **"Use my location"** — browser geolocation, with a coarse third-party IP-address
lookup as a fallback when the browser cannot answer. Pressing the button is what
triggers the request; declining the browser permission prompt stops there, with no
IP fallback. Worth knowing before using it on a classroom machine.
- **N / P** — cycle only among named stars and planets currently in the field of view.

### Coordinate systems (overlays)
Expand Down Expand Up @@ -192,7 +197,7 @@ You can open the sim with a prepared observer and epoch in the URL, for example:

`?lat=-33.9&lon=151.2&date=2024-12-21T10:00:00Z&fov=60&magLimit=4`

Useful parameters: `lat`, `lon`, `date` (ISO-8601 UTC), `fov`, `magLimit`, plus
Useful parameters: `lat`, `lon`, `date` (ISO-8601 UTC, 1900–2100), `fov`, `magLimit`, plus
optional toggles for star names, constellation lines, planet names, and the
deeper star catalog. Full list:
[implementation-notes.md](./implementation-notes.md#deep-link-query-parameters).
Expand Down
20 changes: 13 additions & 7 deletions src/ZenithConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,6 @@ export const LONGITUDE_RANGE = new Range(-180, 180);
/** Arrow-key nudge (degrees) when dragging the observer pin on the location map. */
export const LOCATION_STEP_DEGREES = 5;

/**
* Default local sidereal time is derived from civil time + longitude at reset;
* this constant is only used as a fallback before the first sync.
*/
export const DEFAULT_LOCAL_SIDEREAL_TIME_HOURS = 0;

/**
* Hours of civil (and roughly sidereal) time advanced per second of simulation
* clock at the base `1×` rate. Real-time: one wall-clock second advances civil
Expand Down Expand Up @@ -114,6 +108,18 @@ export const CIVIL_DAY_RANGE = new Range(1, 31);
/** UTC hour-of-day range for the civil date jump UI. */
export const CIVIL_HOUR_RANGE = new Range(0, 23);

/**
* Supported span of civil time in UTC milliseconds, derived from
* {@link CIVIL_YEAR_RANGE}. This is the authoritative bound: the date-jump
* spinners, the `date` deep-link parameter, and clock playback are all held
* inside it, so the Year spinner can never display a value outside its own
* range and a shared link can never resolve to an epoch the UI cannot express.
*/
export const CIVIL_TIME_MS_RANGE = new Range(
Date.UTC(CIVIL_YEAR_RANGE.min, 0, 1, 0, 0, 0),
Date.UTC(CIVIL_YEAR_RANGE.max, 11, 31, 23, 0, 0),
);

/**
* Discrete time-rate ladder: each value multiplies the base educational rate
* {@link CIVIL_HOURS_PER_SIM_SECOND}. Symmetric with no zero, so stepping the
Expand Down Expand Up @@ -306,11 +312,11 @@ ZenithNamespace.register("ZenithConstants", {
CONTROL_PANEL_WIDTH,
DEFAULT_LATITUDE_DEG,
DEFAULT_LONGITUDE_DEG,
DEFAULT_LOCAL_SIDEREAL_TIME_HOURS,
CIVIL_HOURS_PER_SIM_SECOND,
HOURS_PER_SIDEREAL_DAY,
DEFAULT_CIVIL_TIME_MS,
CIVIL_YEAR_RANGE,
CIVIL_TIME_MS_RANGE,
CIVIL_MONTH_RANGE,
CIVIL_DAY_RANGE,
CIVIL_HOUR_RANGE,
Expand Down
7 changes: 7 additions & 0 deletions src/i18n/StringManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,17 @@ import stringsEn from "./strings_en.json";
import stringsEs from "./strings_es.json";
import stringsFr from "./strings_fr.json";

// Compile-time locale parity: every locale must carry exactly the same key set.
// Both directions are asserted for each pair, so a key missing from — or added to —
// any one locale is a build error (`npm run check`), not a runtime fallback.
// biome-ignore lint/complexity/noVoid: intentional compile-time type assertion
void (stringsEn satisfies typeof stringsFr);
// biome-ignore lint/complexity/noVoid: intentional compile-time type assertion
void (stringsFr satisfies typeof stringsEn);
// biome-ignore lint/complexity/noVoid: intentional compile-time type assertion
void (stringsEn satisfies typeof stringsEs);
// biome-ignore lint/complexity/noVoid: intentional compile-time type assertion
void (stringsEs satisfies typeof stringsEn);

const stringProperties = LocalizedString.getNestedStringProperties({
en: stringsEn,
Expand Down
10 changes: 3 additions & 7 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,9 @@ onReadyToLaunch(() => {
},
}),

// Optional: fill in credits shown in Help → About
credits: {
leadDesign: "",
softwareDevelopment: "",
team: "",
qualityAssurance: "",
},
// `credits` is deliberately omitted: joist renders a row for every key it is
// given, so empty strings produce blank labelled rows in Help → About. Add
// the keys back only with real names. Project attribution lives in CREDITS.md.
});

sim.start();
Expand Down
9 changes: 3 additions & 6 deletions src/preferences/ZenithPreferencesModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,9 @@ export class ZenithPreferencesModel {
});
}

public reset(): void {
this.showStarLabelsProperty.reset();
this.showConstellationsProperty.reset();
this.showPlanetLabelsProperty.reset();
this.deepStarCatalogProperty.reset();
}
// Deliberately no reset(): these overlay toggles are preference-backed and
// outlive Reset All (see ZenithModel.reset). An unused reset() here would
// invite wiring it into Reset All and silently breaking that contract.
}

ZenithNamespace.register("ZenithPreferencesModel", ZenithPreferencesModel);
23 changes: 17 additions & 6 deletions src/preferences/zenithQueryParameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import { logGlobal } from "scenerystack/phet-core";
import { QueryStringMachine } from "scenerystack/query-string-machine";
import {
CIVIL_TIME_MS_RANGE,
DEFAULT_CIVIL_TIME_MS,
DEFAULT_DEEP_STAR_CATALOG,
DEFAULT_FIELD_OF_VIEW_DEG,
Expand All @@ -34,26 +35,35 @@ import {
import ZenithNamespace from "../ZenithNamespace.js";

/**
* Returns true when `value` is empty/null (use sim default) or a parseable civil date.
* Returns true when `value` is empty/null (use sim default), or a parseable
* civil date that falls inside the span the simulation can actually represent
* ({@link CIVIL_TIME_MS_RANGE}, i.e. the `CIVIL_YEAR_RANGE` the date-jump
* spinners cover). Parseability alone is not enough: an out-of-range epoch would
* be silently clamped to the range boundary further down, so a shared link would
* quietly render a different sky than the one it names. Rejecting it here makes
* QueryStringMachine warn and fall back to the documented default instead.
*
* Used by QueryStringMachine `isValidValue` for the `date` parameter.
* `StringType` from QueryStringMachine includes `null`, so accept that here.
*/
export function isValidCivilDateQueryParam(value: string | null): boolean {
if (value === null || value === "") {
return true;
}
return !Number.isNaN(Date.parse(value));
const ms = Date.parse(value);
return !Number.isNaN(ms) && CIVIL_TIME_MS_RANGE.contains(ms);
}

/**
* Parses a civil-date query string to UTC ms, or null when empty / invalid.
* Parses a civil-date query string to UTC ms, or null when empty, unparseable,
* or outside {@link CIVIL_TIME_MS_RANGE}.
*/
export function parseCivilDateQueryParam(value: string | null): number | null {
if (value === null || value === "") {
return null;
}
const ms = Date.parse(value);
return Number.isNaN(ms) ? null : ms;
return Number.isNaN(ms) || !CIVIL_TIME_MS_RANGE.contains(ms) ? null : ms;
}

/**
Expand Down Expand Up @@ -86,8 +96,9 @@ const zenithQueryParameters = QueryStringMachine.getAll({
},

/**
* Civil UTC timestamp (`Date.parse` / ISO-8601). Empty string uses the sim default.
* Example: `?date=2024-12-21T10:00:00Z`.
* Civil UTC timestamp (`Date.parse` / ISO-8601), within the supported civil
* year range (1900–2100). Empty string — or a date outside that range — uses
* the sim default. Example: `?date=2024-12-21T10:00:00Z`.
*/
date: {
type: "string",
Expand Down
38 changes: 32 additions & 6 deletions src/zenith-screen/model/ZenithModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type { ZenithPreferencesModel } from "../../preferences/ZenithPreferences
import zenithQueryParameters, { resolveCivilTimeMsFromQuery } from "../../preferences/zenithQueryParameters.js";
import {
CIVIL_HOURS_PER_SIM_SECOND,
CIVIL_TIME_MS_RANGE,
DEFAULT_CIVIL_TIME_MS,
DEFAULT_LATITUDE_DEG,
DEFAULT_LONGITUDE_DEG,
Expand Down Expand Up @@ -65,6 +66,7 @@ import { DEFAULT_LOCATION_PRESET, LOCATION_PRESET_COORDS, LocationPreset } from
import type { SelectedSkyObject } from "./SelectedSkyObject.js";

const MS_PER_HOUR = 3600 * 1000;
const MS_PER_MINUTE = 60 * 1000;

/**
* Single instantaneous ephemeris for the whole solar system at one civil time /
Expand Down Expand Up @@ -269,7 +271,7 @@ export class ZenithModel implements TModel {
this.longitudeProperty = new NumberProperty(startLon, {
range: LONGITUDE_RANGE,
});
this.civilTimeMsProperty = new NumberProperty(startCivilMs);
this.civilTimeMsProperty = new NumberProperty(CIVIL_TIME_MS_RANGE.constrainValue(startCivilMs));
this.localSiderealTimeHoursProperty = new NumberProperty(localSiderealTimeHours(startCivilMs, startLon));
this.lookAzimuthDegProperty = new NumberProperty(DEFAULT_LOOK_AZIMUTH_DEG);
this.lookAltitudeDegProperty = new NumberProperty(DEFAULT_LOOK_ALTITUDE_DEG, {
Expand Down Expand Up @@ -391,10 +393,24 @@ export class ZenithModel implements TModel {
this.disposers.push(() => this.latitudeProperty.unlink(markLocationCustom));
this.disposers.push(() => this.longitudeProperty.unlink(markLocationCustom));

const markEpochCustom = (): void => {
if (!this.applyingPreset) {
this.epochPresetProperty.value = EpochPreset.CUSTOM;
// Leaving a named epoch marks it CUSTOM — but only once the clock has moved
// far enough for the UTC readout (minute precision) to disagree with the
// preset. Without that tolerance the timer, which starts playing, advances
// civil time by a few milliseconds on the very first step and flips the combo
// to "Custom time" while the readout still shows the preset epoch — so the
// selector could never display a preset, not even right after one is chosen.
const markEpochCustom = (civilMs: number): void => {
if (this.applyingPreset) {
return;
}
const preset = this.epochPresetProperty.value;
if (preset !== EpochPreset.CUSTOM) {
const presetMs = EPOCH_PRESET_CIVIL_MS.get(preset);
if (presetMs !== undefined && Math.abs(civilMs - presetMs) < MS_PER_MINUTE) {
return;
}
}
this.epochPresetProperty.value = EpochPreset.CUSTOM;
};
this.civilTimeMsProperty.lazyLink(markEpochCustom);
this.disposers.push(() => this.civilTimeMsProperty.unlink(markEpochCustom));
Expand Down Expand Up @@ -456,7 +472,17 @@ export class ZenithModel implements TModel {

/** Jumps civil time to the observer's real-world current instant ("Now"). */
public setToNow(): void {
this.civilTimeMsProperty.value = Date.now();
this.setCivilTimeMs(Date.now());
}

/**
* Sets civil time, held inside {@link CIVIL_TIME_MS_RANGE}. Every write that
* can leave the supported span (playback, scrubbing, "Now") goes through here,
* so the clock stops at the range edge rather than drifting to an epoch the
* date-jump spinners cannot represent.
*/
public setCivilTimeMs(civilTimeMs: number): void {
this.civilTimeMsProperty.value = CIVIL_TIME_MS_RANGE.constrainValue(civilTimeMs);
}

/**
Expand Down Expand Up @@ -503,7 +529,7 @@ export class ZenithModel implements TModel {

/** Advances civil time by `hours` (educational scrub / Ctrl-drag). */
public advanceCivilTimeHours(hours: number): void {
this.civilTimeMsProperty.value += hours * MS_PER_HOUR;
this.setCivilTimeMs(this.civilTimeMsProperty.value + hours * MS_PER_HOUR);
this.syncLocalSiderealTime();
}

Expand Down
14 changes: 10 additions & 4 deletions src/zenith-screen/view/ObjectNameSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import { DerivedProperty, Multilink, PatternStringProperty, Property, type TReadOnlyProperty } from "scenerystack/axon";
import type { OneKeyStroke } from "scenerystack/scenery";
import { KeyboardListener, Node, Rectangle, Text, VBox } from "scenerystack/scenery";
import { Color, KeyboardListener, Node, Rectangle, Text, VBox } from "scenerystack/scenery";
import { PhetFont } from "scenerystack/scenery-phet";
import { LIGHT_SURFACE_TEXT_FILL } from "../../common/ZenithButtonOptions.js";
import { ZenithPanel } from "../../common/ZenithPanel.js";
Expand Down Expand Up @@ -151,14 +151,19 @@ export class ObjectNameSearch extends Node {

// ── Result rows ─────────────────────────────────────────────────────────────
const rowFont = new PhetFont(CONTROL_FONT_SIZE);
// Result rows sit directly on the dark panel fill, not on the white field
// surface, so they take the panel text color. Using LIGHT_SURFACE_TEXT_FILL
// here (near-black on deep indigo) renders them at ~1:1 contrast. The
// highlighted row inverts: dark text on the light accent fill.
const createRow = (entry: Entry, active: boolean): Node => {
const background = new Rectangle(0, 0, ROW_WIDTH, ROW_HEIGHT, {
cornerRadius: 3,
fill: active ? ZenithColors.accentColorProperty : "rgba(0,0,0,0)",
// Transparent (not null) so the whole row stays a pointer hit target.
fill: active ? ZenithColors.accentColorProperty : Color.TRANSPARENT,
});
const label = new Text(entry.nameProperty, {
font: rowFont,
fill: active ? ZenithColors.controlSurfaceColorProperty : LIGHT_SURFACE_TEXT_FILL,
fill: active ? LIGHT_SURFACE_TEXT_FILL : ZenithColors.textColorProperty,
maxWidth: ROW_WIDTH - 12,
});
label.left = 6;
Expand All @@ -174,9 +179,10 @@ export class ObjectNameSearch extends Node {
);

// ── "No matches" line + list visibility ────────────────────────────────────
// Also on the dark panel fill, not the white field — panel text color.
const statusText = new Text(controls.searchNoMatchesStringProperty, {
font: rowFont,
fill: LIGHT_SURFACE_TEXT_FILL,
fill: ZenithColors.textColorProperty,
maxWidth: FIELD_WIDTH,
});
Multilink.multilink([focusedProperty, queryProperty, matchesProperty], (isFocused, q, matches) => {
Expand Down
4 changes: 3 additions & 1 deletion src/zenith-screen/view/ObserverLocationNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Shape } from "scenerystack/kite";
import { ModelViewTransform2 } from "scenerystack/phetcommon";
import {
Circle,
Color,
DragListener,
KeyboardListener,
Line,
Expand Down Expand Up @@ -215,7 +216,8 @@ export class ObserverLocationNode extends Node {
const pin = new Node({
cursor: "grab",
children: [
new Circle(12, { fill: "rgba(0,0,0,0)" }),
// Transparent (not null) so the disk still hit-tests as a grab target.
new Circle(12, { fill: Color.TRANSPARENT }),
new Line(-9, 0, 9, 0, { stroke: ZenithColors.locationPinColorProperty, lineWidth: 1.5 }),
new Line(0, -9, 0, 9, { stroke: ZenithColors.locationPinColorProperty, lineWidth: 1.5 }),
new Circle(3.5, { fill: ZenithColors.locationPinColorProperty, stroke: "#ffffff", lineWidth: 0.75 }),
Expand Down
Loading