diff --git a/doc/implementation-notes.md b/doc/implementation-notes.md index aeca443..436b29b 100644 --- a/doc/implementation-notes.md +++ b/doc/implementation-notes.md @@ -1,22 +1,28 @@ # Implementation Notes - Solar System Models -## Architecture Overview +## Architecture overview -TemplateSingleSim is a minimal starter scaffold for forking new single-screen SceneryStack simulations. It demonstrates the Model-View pattern, color profiles, localization, reset behavior, and reusable common components without domain-specific physics. - -### High-Level Architecture +Two independent screens, each a standard `Screen` pairing (see +[.github/CLAUDE.md](https://github.com/OpenPhysics/.github/blob/main/CLAUDE.md) for the general +SceneryStack Model/View pattern this follows). The screens share no model state — each owns its own +`Model` — but share layout/color/i18n infrastructure at the `src/` root and in `src/common/`. ``` main.ts - └─ SolarSystemModelsScreen (Screen) - ├─ SolarSystemModelsModel state + logic (src/solar-system-models-screen/model/) - └─ SolarSystemModelsScreenView visuals (src/solar-system-models-screen/view/) - ├─ SolarSystemModelsScreenSummaryContent (PDOM overview) - └─ SolarSystemModelsKeyboardHelpContent (keyboard help dialog) + ├─ PtolemaicScreen (Screen) + │ ├─ PtolemaicModel deferent/epicycle/equant geometry, presets, memory store/recall + │ └─ PtolemaicScreenView orbit diagram, "view from Earth" zodiac strip, path trail + └─ ConfigurationsScreen (Screen) + ├─ ConfigurationsModel Kepler orbits, elongation, synodic event schedule, timeline + └─ ConfigurationsScreenView orbit diagram, elongation indicator, zodiac strip, timeline src/common/ - ├─ SimPanel.ts pre-themed panel (all screens share SolarSystemModelsColors) - └─ TimeModel.ts composable play/pause + elapsed time + ├─ TimeModel.ts composable play/pause + elapsed-time model; both screen models compose one + ├─ CelestialBodyNode.ts Circle auto-positioned from a model Vector2 Property + a reactive + │ ModelViewTransform2 Property; used for Earth/Sun/planet markers + ├─ SolarSystemModelsPanel.ts pre-themed Panel wrapper (all screens share SolarSystemModelsColors) + ├─ ZodiacConstellationNode.ts and ZodiacConstellationsData.ts — the shared starfield background + └─ ZodiacStripBackground.ts shared "band of sky" backdrop used by both screens' zodiac strips src/preferences/ ├─ SolarSystemModelsPreferencesModel sim-specific pref state @@ -24,21 +30,44 @@ src/preferences/ └─ solarSystemModelsQueryParameters query-parameter declarations ``` -Data flows Model → View through AXON `Property` objects. The view observes -properties via `.link()` or `.lazyLink()` and updates reactively. +Data flows Model → View through AXON `Property` objects; views observe via `.link()`, `.lazyLink()`, or +`Multilink.multilink()` and update reactively. Neither model imports from its own `view/`. + +## Model components + +### PtolemaicModel + +Owns the deferent/epicycle/equant geometry (see [model.md](./model.md) for the math) as a chain of +`DerivedProperty`s keyed on the adjustable parameters (`epicycleSizeProperty`, `eccentricityProperty`, +`apogeeAngleProperty`, `motionRateProperty`, `planetTypeProperty`) plus the two time-driven angles +(`sunAngleProperty`, `anomalyProperty`), which `step(dt)` advances (wrapped to `[0, 2π)` so they don't +lose precision over a long play session). `applyPreset`/`storeMemory`/`recallMemory` implement the +preset combo box and the memory-recall buttons from the original Flash sim. -## Model Components +### ConfigurationsModel -### SolarSystemModelsModel +Owns the two Kepler orbits (`semimajorAxis{1,2}Property`, `period{1,2}Property`, +`epochAngle{1,2}Property`) and the derived synodic/event schedule +(`synodicPeriodProperty`, `cycleOffsetProperty`, `eventTimesListProperty`, `eventNamesProperty`, +`currentCycleNumberProperty`). The schedule is **entirely derived** — a single internal `DerivedProperty` +recomputes it from the orbital parameters, and the five public Properties above are `TReadOnlyProperty` +views onto that computation, so nothing outside the model can put the schedule out of sync with the +orbits that produced it. -An empty coordinator with documented hooks for `step(dt)` and `reset()`. -Add physics state as `BooleanProperty`, `NumberProperty`, etc. from -`scenerystack/axon`. +Time-driven animation (`step(dt)`) has three mutually exclusive modes, tracked by a private +`"idle" | "slewing" | "countingDown"` union rather than ad-hoc boolean/sentinel fields: + +- **idle** — normal playback; `eventActionProperty` (`RUN`/`PAUSE`/`LOCK`) decides what happens when the + clock crosses the next scheduled event. +- **slewing** — an eased animation (`slewToEvent`) that jumps the clock to a specific event, used when + the user clicks an event on the timeline. +- **countingDown** — used by the `PAUSE` event action: play stops at an event for `pauseTimeProperty` + seconds (tracked by `countdownRemainingProperty`) before automatically resuming. ### TimeModel (common) -`src/common/TimeModel.ts` is a reusable play/pause + elapsed-time model for -animated sims. Compose it into your screen model rather than subclassing: +`src/common/TimeModel.ts` is a reusable play/pause + elapsed-time model for animated sims. Compose it +into a screen model rather than subclassing: ```typescript export class YourModel implements TModel { @@ -52,63 +81,42 @@ export class YourModel implements TModel { } ``` -## View Components - -### SolarSystemModelsScreenView as Coordinator - -The screen view demonstrates layout using `layoutBounds`, background fill from -`SolarSystemModelsColors.ts`, and a `ResetAllButton` wired to `model.reset()`. Add -specialized sub-nodes under `src/solar-system-models-screen/view/`. - -### SimPanel (common) - -`src/common/SimPanel.ts` wraps SceneryStack's `Panel` with the sim's color -scheme baked in. All control panels should use `SimPanel` so projector-mode -switching is automatic: - -```typescript -const panel = new SimPanel(content); // defaults -const panel = new SimPanel(content, { xMargin: 20 }); // any PanelOption override -``` - -### Color Scheme +Both `PtolemaicModel` and `ConfigurationsModel` compose a `TimeModel` for `isPlayingProperty` and +`animationRateProperty`, but drive their own `ptolemaicTimeProperty`/`timeProperty` from `step(dt)` +directly (in days and years respectively) rather than using `TimeModel`'s own `timeProperty`. -`SolarSystemModelsColors.ts` defines `ProfileColorProperty` instances for "default" (dark) -and "projector" (light) profiles. SceneryStack switches profiles automatically -when the user toggles Projector Mode in Preferences. +## View components -## Forking this template +### Reactive `ModelViewTransform2` -### Automated rename +`ConfigurationsScreenView`'s orbit diagram rescales whenever either semimajor axis changes (so both +orbits stay fit to the diagram). Its `ModelViewTransform2` is exposed as a `TReadOnlyProperty` (a +`DerivedProperty` of the two axis Properties) rather than a plain mutable object — `CelestialBodyNode` +and `ConfigurationsElongationIndicator` each combine that Property with their model-position Property +via `Multilink.multilink`, so they redraw themselves consistently no matter which dependency changed. +`PtolemaicScreenView`'s transform never changes (that screen has no adjustable orbit radius), so it +wraps its static transform in a constant `Property` to satisfy the same interface. -```sh -npm run rename -- --id friction --name "Friction" -npm run check -``` +### SolarSystemModelsPanel (common) -`scripts/rename-sim.ts` replaces all template identifiers in file content and -renames files and folders in one pass. +`src/common/SolarSystemModelsPanel.ts` wraps SceneryStack's `Panel` with the sim's color scheme baked +in. Both screens' control/display panels use it so projector-mode switching is automatic. -### Manual fork checklist +### Color scheme -- Update `package.json` name, `init.ts` name/version, `brand.ts` -- Replace placeholder view content with play area and control panels -- Replace `SolarSystemModelsColors.ts` colors with sim-specific palette -- Update locale JSON files: title, screen names, a11y strings -- Regenerate PWA icons (`npm run icons`) after editing `public/icons/icon.svg` -- Add `doc/implementation-notes.md` describing the new sim's architecture +`SolarSystemModelsColors.ts` defines `ProfileColorProperty` instances for "default" (dark) and +"projector" (light) profiles; SceneryStack switches profiles automatically when the user toggles +Projector Mode in Preferences. ## Multi-screen simulations -See `doc/multi-screen.md` for a complete guide covering: -- Independent vs. shared-model architectures -- File structure for each screen -- StringManager and locale changes -- Home-screen icon requirements -- Per-screen accessibility strings +See [doc/multi-screen.md](./multi-screen.md) for the general guide this sim's two-screen structure +follows (independent vs. shared-model architectures, per-screen accessibility strings, home-screen icon +requirements). This sim uses the **independent-state** pattern — the two screens share no model — since +the Ptolemaic and Configurations labs are conceptually separate simulators in the original NAAP suite. -## Known gaps / TODOs +## Decompiling the Flash sources -- No dispose() calls yet — add them once Properties gain external listeners. -- `SolarSystemModelsModel.step()` and `reset()` bodies are stubs — fill in with real physics. -- `SolarSystemModelsScreenView` pdomOrder TODO comment — add interactive nodes as they are created. +See the root [CLAUDE.md](../CLAUDE.md) for `npm run decompile`, which extracts the original Flash +simulators' ActionScript into `NAAP/decompiled/` (git-ignored) as a read-only reference for diffing the +port's math against the originals. diff --git a/doc/model.md b/doc/model.md index a8d672b..d45d3d0 100644 --- a/doc/model.md +++ b/doc/model.md @@ -2,36 +2,115 @@ This document describes the model (the underlying physics, math, and behavior) for the simulation, in terms appropriate for an educator. It is the companion to -[implementation-notes.md](./implementation-notes.md), which targets developers. Replace the placeholder -content below with a description of your own sim's model when you fork this template. +[implementation-notes.md](./implementation-notes.md), which targets developers. ## Overview -*One or two paragraphs describing what the simulation models and the key ideas a student should take -away. Write for a teacher, not a programmer — avoid code and class names.* +This sim ports the NAAP *Solar System Models* lab and has two screens that look at the same historical +question — "why do planets sometimes appear to move backward against the stars?" — from two different +models. -The template ships with no domain-specific physics; it is a minimal Model-View scaffold. A real sim -replaces this section with its actual conceptual model. +- **Ptolemaic System** reconstructs the Earth-centered (geocentric) explanation: each planet rides a + small circle (the *epicycle*) whose center travels around a larger circle (the *deferent*) centered + near, but not exactly on, the Earth. This combination of circles-on-circles reproduces retrograde + motion without needing a Sun-centered system. +- **Planetary Configurations** shows the Sun-centered (heliocentric) explanation: both bodies orbit the + Sun on simple ellipses (circular in this sim), and retrograde motion is just the geometric effect of + Earth periodically overtaking (or being overtaken by) a slower or faster planet. This screen also + identifies and schedules the recurring alignments — opposition, conjunction, greatest elongation — + that Earth and the other planet form as they orbit. -## Quantities and units +Students can compare the two models directly: the same retrograde behavior an observer sees from Earth +falls out of very different underlying geometries. -*List the primary modeled quantities, their symbols, units (SI where applicable), and ranges.* +## Quantities and units | Quantity | Symbol | Units | Range | |---|---|---|---| -| *(example)* time | t | s | 0 – ∞ | +| Epicycle size (Ptolemaic) | R_e | deferent radii | 0 – 1 | +| Eccentricity (Ptolemaic) | ecc | deferent radii | 0 – 0.5 | +| Motion rate (Ptolemaic) | — | °/day | 0 – 4.5 | +| Apogee angle (Ptolemaic) | — | degrees | 0 – 360 | +| Elapsed time (Ptolemaic) | t | days | 0 – ∞ | +| Semimajor axis (Configurations) | a₁, a₂ | astronomical units (AU) | 0.1 – 15 | +| Orbital period (Configurations) | P | years | derived from a (Kepler) | +| Elongation | — | degrees, signed (− = East, + = West) | −180 – 180 | +| Elapsed time (Configurations) | t | years | 0 – ∞ | ## Governing equations -*State the equations or rules that drive the model, with a sentence explaining each. Use a smaller -fixed time step than the screen default if the integration requires it; the model makes no assumption -about frame rate.* +### Ptolemaic System — deferent, epicycle, and equant + +The deferent has a fixed radius (normalized to 1) but is offset from Earth by the eccentricity, along +the apogee direction: + +``` +deferentCenter = eccentricity · (cos apogeeAngle, sin apogeeAngle) +``` + +The *equant* is the historical device that keeps the deferent's motion uniform even though it is +off-center from Earth: it sits twice as far from Earth as the deferent's center, on the same line. +Motion around the deferent is uniform **as seen from the equant**, not from Earth or from the deferent's +own center — this is what let Ptolemy's model match observations without needing an off-center Earth to +also be off-center in its rotation. + +Which angle drives the deferent depends on whether the planet is superior (Mars, Jupiter, Saturn) or +inferior (Mercury, Venus) relative to Earth's orbit: + +- **Superior planets**: the deferent (the "slow" circle) is driven by the planet's own orbital angle + (`anomaly`), and the epicycle (the "fast" circle) is locked to the Sun's angle. +- **Inferior planets**: it's reversed — the deferent follows the Sun's angle, and the epicycle follows + the planet's own orbital angle. + +This swap is what produces retrograde motion at the right cadence for each class of planet: once per +epicycle revolution, the planet's position as seen from Earth briefly reverses direction along the +zodiac. + +### Planetary Configurations — Kepler orbits and synodic events + +Both bodies move on circular orbits around the Sun. Kepler's third law ties each orbit's period to its +radius (in AU and years, the constant of proportionality is 1): + +``` +period = semimajorAxis^1.5 +``` + +An observer on one planet (typically Earth) sees the other planet's apparent position relative to the +Sun — its *elongation* — cycle through recognizable configurations as the two planets' angular +positions drift in and out of alignment. Which configurations are geometrically possible depends on +which orbit is smaller: + +- If the **observer's** orbit is the inner one, the target planet can reach **opposition** (directly + opposite the Sun in the sky) and **conjunction** (behind the Sun), with two quadratures in between. +- If the **observer's** orbit is the outer one, the target planet (now moving faster than the observer) + passes through **inferior conjunction** (between Earth and the Sun) and **superior conjunction** + (behind the Sun), with two greatest-elongation events in between. + +The time between repeats of the same configuration is the **synodic period** — how long it takes the +faster planet to "lap" the slower one as seen from the Sun. It depends only on the two orbital periods, +not on where either planet currently is: + +``` +synodicPeriod = 1 / (1/innerPeriod − 1/outerPeriod) +``` + +Within each synodic cycle, the four named events recur at fixed offsets from a reference time, so the +whole schedule can be predicted and displayed on a timeline rather than only observed as time passes. ## Simplifications and assumptions -*Note anything intentionally idealized or omitted relative to the real-world phenomenon (e.g. no air -resistance, point masses, instantaneous response), so educators can set expectations.* +- All orbits (Ptolemaic deferents and epicycles, and Configurations orbits) are **circular**, not + elliptical — a deliberate simplification shared with the original NAAP labs, since eccentricity's + effect on apparent motion is already the pedagogical point of the *equant*, not orbital ellipticity. +- The Configurations screen models exactly two bodies (an observer and a target) orbiting a fixed Sun; + it does not model mutual gravitational perturbation between planets. +- Distances and angles are normalized model quantities (deferent radii for Ptolemaic geometry, AU for + Configurations), not literal solar-system scale — the Ptolemaic Sun-orbit radius, for instance, is + chosen for a legible diagram, not astronomical accuracy. ## References -*Optional: textbook sections, papers, or standards the model is based on.* +- NAAP *Solar System Models* lab: `NAAP/astroUNL/naap/ssm/modeling.html`, + `NAAP/astroUNL/naap/ssm/naap_ssm_sg.pdf` (student guide). +- Original Flash simulators this sim ports: *Ptolemaic System Simulator* (`ptolemaic.swf`) and + *Planetary Configurations Simulator* (`configurationsSimulator.swf`). diff --git a/plan.md b/plan.md deleted file mode 100644 index 6f67de8..0000000 --- a/plan.md +++ /dev/null @@ -1,401 +0,0 @@ -# Port NAAP "Solar System Models" Flash sims to SceneryStack - -## Context - -This repo (`SolarSystemModels`) is a **scaffold-only** two-screen SceneryStack sim. Both -screens currently render only a placeholder label + Reset All — no model/physics. The goal -is to port the two NAAP Flash labs to faithful SceneryStack TypeScript implementations: - -- **Ptolemaic System** (`src/ptolemaic/`) — Earth-centered deferent/epicycle/equant model + "view from Earth". -- **Planetary Configurations** (`src/configurations/`) — Sun-centered Keplerian orbits + elongation/configuration analysis + a synodic event timeline. - -**The decompiled ActionScript of the original Flash `.swf` files is now available and is the -authoritative source of truth** (`NAAP/decompiled/…`, produced by `npm run decompile`). Transcribe -its math into typed TS. The React ports (`NAAP/Ptolemaic-System-Simulator/`, -`NAAP/planetary-config-react/`) are a useful but **secondary** cross-reference — they simplified or -omitted features the Flash originals have (e.g. the configurations event schedule/timeline, the -Ptolemaic memory/recall). Where they disagree, **the decompiled AS wins.** - -Decisions locked with the user: -- **Full parity, phased** — reproduce every feature, built in independently-shippable phases. -- **Pure vector scenery** — render *everything* with scenery primitives (`Circle`/`Path`/`Line`/`Text`/`ArrowNode`). No external image assets. Zodiac = colored band + sign labels; constellations = markers/labels, not figures. - -### Authoritative reference files (decompiled AS — read-only; transcribe, do NOT vendor) - -All paths under `NAAP/decompiled/`. The package dir is literally named `%3Cdefault package%3E` (URL-encoded ``). - -**Ptolemaic** (`ptolemaic023-C/scripts/`): -| File | Key contents (function → lines) | -|---|---| -| `%3Cdefault package%3E/Ptolemaic System.as` | **Core model.** Constructor/defaults (1–58); `onEnterFrameFunc` time integration (249–260); `update()` equant geometry + Sun/planet/vectors (271–339); `setEccentricity`/`setApogeeAngle` deferent+equant placement (481–516); `setDeferentRadius`/`setDeferentCenter`/`setEquantCenter` drag clamps (358–480); `updatePath` retrograde-trail longitude array (90–211) | -| `frame_1/DoAction.as` | **Controller + preset data.** `planetData[]` preset table (168); `onReset` defaults (1–24); `setPresets` (68–85); slider/checkbox change handlers (108–163); `memoryStore`/`memoryRecall` (25–57) | -| `%3Cdefault package%3E/Zodiac Strip.as` | "View from Earth" strip: `setSunLongitude`/`setPlanetLongitude` longitude→x mapping + ghosting trail (39–184); width 600, factor `600/2π ≈ 95.49296` | -| `%3Cdefault package%3E/New Sun.as` | Sun drag → `setSunAngle` (8–18) | - -**Configurations** (`configurationsSimulator044-C/scripts/`): -| File | Key contents (function → lines) | -|---|---| -| `%3Cdefault package%3E/Configurations Simulator.as` | **Core model.** `update()` angles/longitudes/elongation (56–94); `calculateSystemProperties` synodic period + event schedule (591–628); `setSemimajorAxis` Kepler period (563–590); `calculateAnimationRate` (99–102); `setTime`/`setTimeByCycleAndEventNumbers` snap-to-events (235–335); `setTimeByPlanetAngle`/`setEpochAngleByPlanetAngle` drag (336–478); `animateOnEnterFrame`/`slewToEvent`/countdown (107–230) | -| `%3Cdefault package%3E/Orbits Diagram.as` | Sun-centered view: `onSystemPropertiesChanged` orbit scaling (271–300); `onSimulatorUpdated` planet placement + elongation arc/arrows/label (180–270); `drawArc` (125–173); colors (19–25) | -| `%3Cdefault package%3E/Timeline.as` | Scrolling synodic timeline: scale/units (195–289); scrub-drag (95–113); time readout "yr, (yr, days)" (120–190) | -| `%3Cdefault package%3E/Orbits Diagram Planet.as` | Planet drag → `setTimeByPlanetAngle`; **Shift-drag** → `setEpochAngleByPlanetAngle` (14–81) | -| `%3Cdefault package%3E/Zodiac Strip.as` | Config "view from Earth" strip (466 lines) + constellation areas/names | -| `%3Cdefault package%3E/Timeline Event Item.as`, `Timeline Event Cycle.as` | Event markers/labels on the timeline | - -Educational background: `NAAP/astroUNL/naap/ssm/modeling.html`, `NAAP/astroUNL/naap/ssm/naap_ssm_sg.pdf`. - ---- - -## Conventions for every phase (read before editing) - -These bind all phases. A weaker LLM must follow them literally. - -1. **Replicate existing scaffold patterns.** Study `src/ptolemaic/PtolemaicScreen.ts`, - `model/PtolemaicModel.ts`, `view/PtolemaicScreenView.ts` and their `Configurations*` twins — - they show exact `Screen` wiring, `implements TModel`, `step(dt)`/`reset()`, - `screenSummaryContent` in `super()`, the bottom-right `ResetAllButton`, and the `pdomOrder` - wrapper `Node`. New code mirrors these. -2. **Reactive state only.** All model state is `scenerystack/axon` Properties (`NumberProperty`, - `BooleanProperty`, `EnumerationProperty`, `DerivedProperty`). Views never compute physics; they - `.link(...)` to model Properties. `reset()` calls `.reset()` on every Property. -3. **Model–view separation via `ModelViewTransform2`** (`scenerystack/phetcommon`). Models work in - physical units (normalized "deferent radii" + days for Ptolemaic; AU + years for Configurations). - **Models use standard math coordinates (y up); the transform flips y for the view.** The Flash AS - negates `sin`/`_y` everywhere because Flash screen-y points down — do NOT copy those negations - into the model; let an inverted-Y `ModelViewTransform2` handle it. -4. **No magic numbers.** Layout px → `src/SolarSystemModelsConstants.ts`. Physics constants → new - constants files (below). Colors → `src/SolarSystemModelsColors.ts` (always BOTH `default` and - `projector`; seed from the AS hex values where given). -5. **Strings & a11y.** No hard-coded English in views. Every visible string and every - `accessibleName`/`accessibleHelpText` comes from `StringManager` (add keys to all three locale - JSONs `src/i18n/strings_{en,fr,es}.json` — the `satisfies` checks in `StringManager.ts` fail the - build if any locale is missing a key; copying English into fr/es as placeholders is acceptable). -6. **TypeScript is strict** (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, - `noUnusedLocals/Parameters`, `noExplicitAny`, `noConsole`). Use `import type` for type-only - imports. Local imports use `.js` extensions. -7. **Gate after every phase:** `npm run check && npm run lint && npm run build && npm test`. Run - `npm run fix` to auto-format. -8. **Verify imports exist** by grepping `node_modules/scenerystack/` before use. Expected paths: - `scenerystack/scenery` (Node, Circle, Path, Line, Rectangle, Text, RichText, VBox, HBox, AlignBox, - DragListener), `scenerystack/scenery-phet` (ResetAllButton, TimeControlNode, ArrowNode, - NumberControl, PhetFont), `scenerystack/sun` (Panel, Checkbox, ComboBox, AquaRadioButtonGroup, - HSlider, RectangularPushButton), `scenerystack/axon`, `scenerystack/dot` (Vector2, Range, Bounds2, - Utils, Dimension2), `scenerystack/kite` (Shape), `scenerystack/phetcommon` (ModelViewTransform2), - `scenerystack/phet-core` (Enumeration, EnumerationValue, optionize). - ---- - -## Physics appendix (transcribed from the decompiled AS — the heart of the port) - -> Encode these in normalized model units. Use standard math coords (y up); drop the AS's screen-y -> negations. Choose `daysPerSecond` / `yearsPerSecond` animation-scale constants and tune in verification. - -### A. Ptolemaic model (ref `Ptolemaic System.as`) - -Normalized fixed constants: deferent radius `R = 1`; **Sun orbit radius = 2.25** (AS: 225px / 100px -deferent — NOT 3 as the React port used); `DAYS_PER_YEAR = 365.24667`. - -Adjustable parameters (slider ranges from React `PlanetaryParameters.jsx`, presets are authoritative -from AS `planetData[]` line 168): -- `epicycleSize` `R_e` ∈ [0, 1] -- `eccentricity` `ecc` ∈ [0, 0.5] (model units; AS multiplies slider by 100 to get px) -- `motionRate` ∈ [0, 4.5] (degrees/day) -- `apogeeAngle` (deg) ∈ [0, 360] -- `planetType` ∈ {SUPERIOR, INFERIOR} - -Exact preset table (`planetData[]`): -| Planet | epicycleSize | ecc | apogee° | motionRate | type | -|---|---|---|---|---|---| -| Venus | 0.719444 | 0.020833 | 46.167 | 1.6021 | inferior | -| Mars | 0.658333 | 0.10 | 106.667 | 0.52406 | superior | -| Jupiter | 0.191667 | 0.045833 | 152.15 | 0.0831224 | superior | -| Saturn | 0.108333 | 0.056944 | 224.167 | 0.0334883 | superior | -Reset selects **Mars** (index 1); also: animationRate slider 100, pathDuration 2.5 yr, showEpicycle & -showDeferent on, all other toggles off. - -Time integration (model time in **days**). Maintain `sunAngleProperty`, `anomalyProperty`, -`timeProperty(days)`. Rates (per day): `sunRate = 2π/DAYS_PER_YEAR` (Sun 1 rev/yr); -`anomalyRate = motionRate · π/180`. Per step of `dtDays`: -``` -time += dtDays -anomaly += anomalyRate · dtDays -sunAngle += sunRate · dtDays -``` -`dtDays = dt · daysPerSecond · animationRate` (`daysPerSecond` tunable; AS default animationRate 0.1). - -Equant geometry (the pedagogical core — AS `update()` 271–339, the clean **asin** form): -``` -apogee = toRadians(apogeeAngle) -deferentCenter = ecc · (cos apogee, sin apogee) # offset from Earth (origin) -equant = 2·ecc · (cos apogee, sin apogee) # twice the offset, collinear -equantAngle = apogee # = atan2(equant−deferentCenter) -equantDistance = ecc # |equant − deferentCenter| - -drive = (planetType == SUPERIOR) ? anomaly : sunAngle # which angle drives the deferent -φ = drive − equantAngle -s = (equantDistance / R) · sin(π − φ) # clamp to [−1, 1] -ψ = φ − asin(s) -θ_def = equantAngle + ψ # true deferent angle (uniform about equant) -epicycleCenter = deferentCenter + R · (cos θ_def, sin θ_def) - -epiDrive = (planetType == SUPERIOR) ? sunAngle : anomaly # epicycle driven by the OTHER angle -planet = epicycleCenter + R_e · (cos epiDrive, sin epiDrive) - -eclipticLongitude = atan2(planet.y, planet.x) # planet as seen from Earth at origin -sun = 2.25 · (cos sunAngle, sin sunAngle) -sunLongitude = sunAngle -``` -Expose `planetPositionProperty`, `epicycleCenterProperty`, `equantPositionProperty`, -`deferentCenterProperty`, `sunPositionProperty`, `eclipticLongitudeProperty`, `sunLongitudeProperty` -as `DerivedProperty`s. Note the SUPERIOR/INFERIOR swap: superior planets have the deferent driven by -the slow `anomaly` and the epicycle locked to the Sun (`sunAngle`); inferior planets are the reverse. - -### B. Configurations model (ref `Configurations Simulator.as`) - -Observer = planet **1** (`semimajorAxis1`, default 1.00 AU = Earth); target = planet **2** -(`semimajorAxis2`, default 2.40 AU). Adjustable: `a1`, `a2` ∈ [≈0.25, 10] AU (confirm slider min/max -from frame init); presets Mercury 0.39, Venus 0.72, Earth 1.00, Mars 1.52, Jupiter 5.20, Saturn 9.54. -Time in **years**. - -Kepler + position (AS `setSemimajorAxis` 571, `update()` 56–67): -``` -period_i = a_i ^ 1.5 # years -angle_i = (epochAngle_i + 2π · time / period_i) mod 2π -pos_i = a_i · (cos angle_i, sin angle_i) # Sun at origin -``` -Elongation (AS 66–87) — **mind the sign convention**: -``` -planetLongitude = atan2(pos2.y − pos1.y, pos2.x − pos1.x) mod 2π # observer→target -sunLongitude = atan2(−pos1.y, −pos1.x) mod 2π # observer→Sun -deg = ((sunLongitude − planetLongitude)·180/π) mod 360 ; if deg>180: deg −= 360 # (−180,180] -elongationValue = |deg|, made negative iff (deg<0 and deg≠180) # negative = EAST -label: value<0 → "E"; value>0 and ≠180 → "W"; 0/180 → none -``` -Synodic period + named-configuration event schedule (AS `calculateSystemProperties` 591–628): -``` -inner = planet with smaller a ; outer = larger a -synodicPeriod = 1 / (1/period_inner − 1/period_outer) -ω_syn = 2π / synodicPeriod -t_q = acos(a_inner / a_outer) / ω_syn -eventTimesList = [0, t_q, synodicPeriod/2, synodicPeriod − t_q] -cycleOffset = ((epochAngle_outer − epochAngle_inner) mod 2π) / ω_syn -eventNames = (observer is inner) - ? ["opposition", "quadrature (eastern)", "conjunction", "quadrature (western)"] - : ["inferior conjunction", "greatest elongation (western)", "superior conjunction", "greatest elongation (eastern)"] -``` -Absolute event time for cycle `c`, event `e`: `cycleOffset + c·synodicPeriod + eventTimesList[e]`. -Animation rate (AS 99–102): `rate = sliderValue · min(period1, period2) / 2π`; `dtYears = dt · yearsPerSecond · rate`. - -Derived Properties to expose: `pos1`, `pos2`, `elongationDeg` (signed), `elongationLabel`, -`synodicPeriod`, `eventTimesList`, `cycleOffset`, `eventNames`, plus `currentConfigurationProperty` -(nearest event name within a tolerance, for readouts/a11y). Orbit scaling (AS `Orbits Diagram` 271–300): -`scale = (viewWidth − 2·orbitMargin)/max(a1,a2)`, `screenRadius_i = scale·a_i/2`. - ---- - -## Phase 1 — Shared foundation (`src/common/`) - -- **`src/SolarSystemModelsConstants.ts`** (edit): add `PTOLEMAIC_DEFERENT_RADIUS = 1`, - `PTOLEMAIC_SUN_RADIUS = 2.25`, `DAYS_PER_YEAR = 365.24667`, `daysPerSecond`/`yearsPerSecond` - animation scales, AU preset radii, parameter ranges/defaults. Keep `Namespace.register` updated. -- **`src/common/AnimationModel.ts`** (or extend `TimeModel`): existing `TimeModel` - (`src/common/TimeModel.ts`) has `isPlayingProperty` + `timeProperty` only. Both sims need a - **continuous animation-rate slider**. Add an `animationRateProperty: NumberProperty`. Keep - `TimeModel` reusable; document the choice. -- **`src/common/CelestialBodyNode.ts`**: reusable vector body — `Circle` + optional label `Text`, - positioned via a model `Vector2` Property + `ModelViewTransform2`, accepts `accessibleName`, and an - optional `DragListener` hook (used by Sun/planet drag). Used for Earth, Sun, planets, markers. -- **Colors** (`src/SolarSystemModelsColors.ts`): add `earthColorProperty`, `sunColorProperty` (AS gold - `#f5c242`), `planetColorProperty`, `observerPlanetColorProperty` (AS `0x8398BC`), - `targetPlanetColorProperty` (AS `0x989898`), `orbitColorProperty` (AS `0xC8C8C8`/`13158600`), - `deferentColorProperty`, `epicycleColorProperty`, `equantColorProperty`, `eccentricColorProperty`, - `vectorColorProperty` (AS `0xA0A0A0`/`10526880`), `elongationColorProperty`, `zodiacBandColorProperty` - — each with default + projector. - -**Done when:** files compile, gate passes, nothing wired into screens yet. - ---- - -## Phase 2 — Ptolemaic model physics (`src/ptolemaic/model/`) - -- **`src/ptolemaic/model/PtolemaicPlanet.ts`** — enum/record of the 4 presets (table A, exact values). -- **`src/ptolemaic/model/PtolemaicModel.ts`** — `implements TModel`. Properties: `epicycleSizeProperty`, - `eccentricityProperty`, `motionRateProperty`, `apogeeAngleProperty` (deg), `planetTypeProperty` - (`EnumerationProperty`), composed animation model, `sunAngleProperty`, `anomalyProperty`. Derived per - appendix A. Methods: `applyPreset(planet)`, `step(dt)`, `setSunAngle(rad)` (for Sun drag), - `resetTime()`, `reset()` (Mars defaults). Implement the **asin equant** exactly; clamp `s∈[−1,1]`. -- **`src/ptolemaic/model/PtolemaicModel.test.ts`** — assert hand-computed planet position/longitude for - a known parameter set; verify retrograde (ecliptic longitude non-monotonic) for the **Mars** preset - over one cycle; verify SUPERIOR vs INFERIOR swap; verify `reset()`/`resetTime()`. - -**Done when:** model tests pass, gate passes. No view yet. - ---- - -## Phase 3 — Ptolemaic orbital view + controls (`src/ptolemaic/view/`) - -- **`PtolemaicScreenView.ts`** — replace placeholder. `ModelViewTransform2` - (`createSinglePointScaleInvertedYMapping`) mapping origin → a left-region center, scale so radius ~3 - fits. Vector children, each `.link()`ed to model derived Properties: Earth at origin; Sun - `CelestialBodyNode` (drag added in Phase 4); planet; deferent circle (`Path` centered at deferent - center); epicycle circle (centered at epicycle center); equant crosshair (`Path`); eccentric-center - dot; vector `ArrowNode`s (planet vector to r≈2.5, equant vector, Earth–Sun line, epicycle–planet - line). 12 zodiac sign labels around the rim (AS constructor 16–35). -- **`PtolemaicControlPanel.ts`** — planet preset `ComboBox` (Venus/Mars/Jupiter/Saturn) → - `applyPreset`; `NumberControl`s for epicycle size, eccentricity, motion rate, apogee angle; - planet-type `AquaRadioButtonGroup`; a **"set preset"** button enabled on manual edits (AS - `setPresetsButton`); **memory store / recall** buttons (AS `memoryStore`/`memoryRecall` 25–57). -- **`PtolemaicDisplayPanel.ts`** — `Checkbox`es: deferent(on), epicycle(on), planet vector(off), - equant vector(off), Earth–Sun line(off), epicycle–planet line(off) — drive node `visibleProperty`. -- **`PtolemaicTimeControls.ts`** — `TimeControlNode` (play/pause + step `model.step(1/60)`), - animation-rate slider, path-duration slider (wired in Phase 4), "reset time" button (`resetTime`). -- **`PtolemaicTimeReadout.ts`** — `RichText` years + days (`DAYS_PER_YEAR`). -- Panels right-edge; `ResetAllButton` bottom-right; update `pdomOrder`; `accessibleName` on every - control from `getPtolemaicA11yStrings()`. - -**Done when:** Mars + Play traces epicycle/retrograde loops; sliders/presets/toggles/memory work; gate passes. - ---- - -## Phase 4 — Ptolemaic "view from Earth" + path trail + Sun drag - -- **`PtolemaicZodiacStrip.ts`** — vector band (width-600 model, `Rectangle` + 12 sign labels). Sun & - planet markers positioned by longitude→x: `x = (longitude · width/2π) mod width` (AS - `setSunLongitude`/`setPlanetLongitude` 39–184). Sun marker uses `−sunAngle` per AS. -- **`PtolemaicPathTrail.ts`** — retrograde geocentric trail. Port AS `updatePath` (90–211) conceptually: - sample planet position over the last `pathDuration·DAYS_PER_YEAR` days into a capped buffer, render a - `Path`; fade by segment alpha. Drive buffer length from the path-duration slider; clear on reset/param - change. (Do NOT replicate Pixi rope interpolation.) Also feed the ghosting trail in the zodiac strip. -- **Sun drag** (AS `New Sun.as`): `DragListener` on the Sun → map pointer angle about Earth to - `model.setSunAngle`. Keyboard-operable (`tagName:"div"`, `focusable:true`, arrow keys nudge) + - `accessibleName`. (Optional advanced: deferent-center/equant drag via AS `setDeferentCenter`/ - `setEquantCenter` — mark as stretch.) - -**Done when:** strip tracks planet/Sun; trail renders & respects duration; dragging the Sun scrubs both -angles; gate passes. - ---- - -## Phase 5 — Configurations model physics (`src/configurations/model/`) - -- **`ConfigurationsPlanet.ts`** — preset radii (Mercury…Saturn, appendix B). -- **`ConfigurationType.ts`** — `Enumeration`: OPPOSITION, CONJUNCTION, INFERIOR_CONJUNCTION, - SUPERIOR_CONJUNCTION, EASTERN_QUADRATURE, WESTERN_QUADRATURE, GREATEST_EASTERN_ELONGATION, - GREATEST_WESTERN_ELONGATION, NONE (names mirror AS `eventNamesList`). -- **`ConfigurationsModel.ts`** — Properties: `semimajorAxis1Property`, `semimajorAxis2Property`, - `epochAngle1Property`, `epochAngle2Property`, composed animation model, `timeProperty(years)`. - Derived: `period1/2`, `angle1/2`, `pos1/2`, `elongationDegProperty` (signed), `elongationLabelProperty`, - `synodicPeriodProperty`, `eventTimesListProperty`, `cycleOffsetProperty`, `eventNamesProperty`, - `currentConfigurationProperty`. Methods: `applyPreset(planetID, planet)`, `setSemimajorAxis(id, a, keepEpoch)` - (Kepler + epoch preservation, AS 563–590), `step(dt)`, `setTime(t, snap?, threshold?)` / - `setTimeByCycleAndEvent(c, e)` (snap-to-events, AS 235–335), `setTimeByPlanetAngle` / - `setEpochAngleByPlanetAngle` (drag, AS 336–478), `resetTime()`, `reset()` (a1=1, a2=2.4). -- **`ConfigurationsModel.test.ts`** — Kepler ratio (Earth ω = 2×… etc.); elongation at known geometry - (inferior conjunction→0°, opposition→180°, E/W sign); `synodicPeriod`, `t_q`, `eventTimesList`, - `cycleOffset`; guard `a1==a2`; reset. - -**Done when:** model tests pass, gate passes. No view yet. - ---- - -## Phase 6 — Configurations orbital view + controls (`src/configurations/view/`) - -- **`ConfigurationsScreenView.ts`** — Sun-centered view. Transform scale from `max(a1,a2)` (AS - `onSystemPropertiesChanged` 271–300) recomputed when radii change (a `DerivedProperty`, - or recompute view radii on link). Nodes: Sun at origin; two orbit circles (`Path`); observer planet - (AS blue) + target planet (AS grey) `CelestialBodyNode`s; "observer's planet"/"target planet" labels - (toggle via "Label orbits"). -- **`ConfigurationsControlPanel.ts`** — observer & target each: preset `ComboBox` + `NumberControl` - (radius AU). Animation-rate slider + `TimeControlNode` (play/pause/step) + "reset time". -- **`ConfigurationsDisplayPanel.ts`** — `Checkbox`es: "Label orbits" (on), "Show elongation angle" - (off), "Snap to events" (on), "Zoom out to view constellations" (off). -- **`ConfigurationsTimeReadout.ts`** — `RichText` "X years, (Y years, Z days)" (AS Timeline 120–190) + - synodic period + current configuration name. -- Panels right-edge; Reset All bottom-right; full `pdomOrder`; `accessibleName`s from - `getConfigurationsA11yStrings()`. - -**Done when:** both orbits animate at Keplerian rates, presets/sliders/zoom work, gate passes. - ---- - -## Phase 7 — Configurations elongation, zodiac strip, timeline, events, drag - -This phase carries the features the React port lacked. Build incrementally; each bullet is shippable. - -- **`ConfigurationsElongationIndicator.ts`** — when "Show elongation angle" on: arrows observer→Sun and - observer→target (`ArrowNode`), arc between them (`Path`/`Shape.arc`), degrees + E/W label. Port AS - `Orbits Diagram.onSimulatorUpdated` (180–270) incl. arc direction by sign and arrowhead scaling. -- **`ConfigurationsZodiacStrip.ts`** — vector "view from Earth" band: Sun + target markers by - longitude→x, large degrees + E/W readout, constellation **labels/markers** (pure-vector). Port config - `Zodiac Strip.as`. -- **`ConfigurationsTimeline.ts`** — vertical scrolling synodic timeline (AS `Timeline.as`): time axis in - years with adaptive unit labels, event markers per cycle (names from `eventNames`), center cursor, - drag-to-scrub time with snap (AS 95–113), selected-event highlight. Bind to model derived Properties. -- **Event actions** — radio group "run / pause / lock" (AS `eventActionGroup`) + pause-duration control - (AS `pauseTimeSlider`, default 5 s). In the animation step, when `time` crosses `nextEventTime` and - action≠run, snap to the event and stop; if "pause", run a countdown then resume (AS - `animateOnEnterFrame` 165–183, `startAnimationCountdown` 107–139). Implement countdown in the - view/model step using elapsed real time. -- **Slew-to-event** — clicking a timeline event eases time to it over ~650 ms with `1−(1−u)³` (AS - `slewToEvent`/`slewOnEnterFrame` 197–230). -- **Planet drag** (AS `Orbits Diagram Planet.as`): `DragListener` on each planet → `setTimeByPlanetAngle`; - **Shift-drag** → `setEpochAngleByPlanetAngle`; pause while dragging; honor snap-to-events; keyboard + - `accessibleName`. - -**Done when:** elongation arc + zodiac strip read correctly; timeline scrubs/snaps; run/pause/lock + -countdown + slew work; dragging a planet (and Shift-drag) updates everything; gate passes. - ---- - -## Phase 8 — Accessibility, i18n, polish, tests - -- **i18n:** move every visible string into `strings_en.json` (+ fr/es placeholders): `screens.*` - (exist), per-screen `a11y..controls.*`, a `units`/`labels` group, and the configuration names. - Keep the `satisfies` key-parity checks green. -- **Live `currentDetails`:** replace the static `a11y..currentDetails` usage in - `PtolemaicScreenSummaryContent.ts` / `ConfigurationsScreenSummaryContent.ts` with a - `DerivedProperty` over model state (e.g. "Mars near opposition; elongation 176° E"). Pattern: - pass the model in, build with `PatternStringProperty`/`DerivedProperty`. -- **Keyboard help:** flesh out `PtolemaicKeyboardHelpContent.ts` / `ConfigurationsKeyboardHelpContent.ts` - (drag, Shift-drag epoch, step, sliders, timeline scrub). -- **Query params / preferences:** add genuinely useful public params to - `solarSystemModelsQueryParameters.ts` (e.g. initial planet); otherwise leave/repurpose `exampleToggle`. -- **Final tests:** edge cases (ecc 0, motionRate 0, equal radii → synodic period guarded). Full gate. - -**Done when:** screen-reader summaries describe live state, strings localized, keyboard help accurate, -full gate green. - ---- - -## Verification (end-to-end) - -1. **Static gate (every phase):** `npm run check && npm run lint && npm run build && npm test`. -2. **Unit math:** `npm test` — `PtolemaicModel.test.ts` and `ConfigurationsModel.test.ts` assert the - transcribed equations against hand-computed values and against the AS formulas (asin equant; synodic - `eventTimesList`/`cycleOffset`; opposition→180°, inferior conjunction→0°; Mars retrograde). -3. **Manual run:** `npm run dev` → `localhost:5173`; drive with the **`/run`** skill (or Playwright MCP): - - **Ptolemaic:** Mars + Play shows epicycle retrograde loops; zodiac strip markers + elongation move; - toggles show/hide; dragging the Sun scrubs time; trail follows & clears on Reset; memory store/recall. - - **Configurations:** Earth + Mars animate (outer slower); "Show elongation" draws the arc; timeline - marks opposition/conjunction/quadrature; snap/lock/pause + slew work; dragging a planet (and - Shift-drag epoch) updates everything; date readout advances. -4. **A11y spot-check:** Tab order matches `pdomOrder`; every control announces a name; `currentDetails` - updates with state. -5. **Cross-check against the decompiled AS** (authoritative) using the reference table above; the React - ports are a secondary sanity check only. - -## Notes / risks for the implementer - -- **Coordinate inversion:** the AS uses Flash screen-y (down). Keep models in math coords and let an - inverted-Y `ModelViewTransform2` flip — do not copy the `−sin`/`−_y` negations into the model. -- **Sun orbit radius is 2.25 deferent radii** (AS), not 3 (React). Use 2.25. -- **Time units differ per screen:** Ptolemaic integrates in **days** (`sunRate = 2π/365.24667`); - Configurations integrates in **years** (`period = a^1.5`). Keep the two animation-scale constants - separate. -- **Elongation sign convention** (Configurations): negative = East, positive = West, per AS — match it - so the timeline event names line up. -- **Equant asin clamp:** clamp `s = (ecc/R)·sin(π−φ)` to `[−1,1]` before `asin` (AS does). -- **Guard `synodicPeriod`** when `a1 == a2` (AS `setSemimajorAxis` rejects equal axes via a 1e-10 check - and the sliders nudge apart — replicate, and display ∞/"never" if it ever occurs). -- The **Timeline + event scheduling** (Phase 7) is the largest net-new subsystem vs. the React port; - budget accordingly and lean on `Configurations Simulator.as` / `Timeline.as` line refs. diff --git a/src/SolarSystemModelsConstants.ts b/src/SolarSystemModelsConstants.ts index 2064238..7072f55 100644 --- a/src/SolarSystemModelsConstants.ts +++ b/src/SolarSystemModelsConstants.ts @@ -45,16 +45,9 @@ export const CONFIGURATIONS_TIMELINE_HEIGHT = 350; // px export const CONFIGURATIONS_TIMELINE_CYCLE_HEIGHT = 120; // px — vertical px per synodic cycle export const CONFIGURATIONS_ELONGATION_ARC_RADIUS = 35; // px — elongation indicator arc radius -// ── Configurations preset orbital radii (AU) ─────────────────────────────────── - -export const PRESET_RADII = { - mercury: 0.39, - venus: 0.72, - earth: 1.0, - mars: 1.52, - jupiter: 5.2, - saturn: 9.54, -} as const; +// Configurations preset orbital radii (AU) live in +// configurations/model/ConfigurationsPlanet.ts, next to the screen that uses +// them — not duplicated here. SolarSystemModelsNamespace.register("SolarSystemModelsConstants", { SCREEN_VIEW_MARGIN, @@ -87,5 +80,4 @@ SolarSystemModelsNamespace.register("SolarSystemModelsConstants", { APOGEE_ANGLE_RANGE, ANIMATION_RATE_RANGE, PATH_DURATION_RANGE, - PRESET_RADII, }); diff --git a/src/common/CelestialBodyNode.ts b/src/common/CelestialBodyNode.ts index 7623c42..4d6052b 100644 --- a/src/common/CelestialBodyNode.ts +++ b/src/common/CelestialBodyNode.ts @@ -1,37 +1,42 @@ import type { TReadOnlyProperty } from "scenerystack/axon"; +import { Multilink } from "scenerystack/axon"; import type { Vector2 } from "scenerystack/dot"; +import { optionize } from "scenerystack/phet-core"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; import type { NodeOptions, TPaint } from "scenerystack/scenery"; import { Circle, Node } from "scenerystack/scenery"; -export type CelestialBodyNodeOptions = { +type CelestialBodyNodeSelfOptions = { radius?: number; - fill?: TPaint; -} & NodeOptions; + fill: TPaint; // no default — every use draws a different body, so callers must specify a color +}; + +export type CelestialBodyNodeOptions = CelestialBodyNodeSelfOptions & NodeOptions; /** - * A Circle, auto-positioned via a model Vector2 Property and a - * ModelViewTransform2. Used for Earth, Sun, planets, and markers. + * A Circle, auto-positioned via a model Vector2 Property and a reactive + * ModelViewTransform2 Property (so it redraws correctly if the transform's + * scale changes, not just when the model position changes). Used for Earth, + * Sun, planets, and markers. */ export class CelestialBodyNode extends Node { public constructor( positionProperty: TReadOnlyProperty, - mvt: ModelViewTransform2, - providedOptions?: CelestialBodyNodeOptions, + mvtProperty: TReadOnlyProperty, + providedOptions: CelestialBodyNodeOptions, ) { - const radius = providedOptions?.radius ?? 8; - const fill = providedOptions?.fill ?? "#ffffff"; - - // Extract CelestialBodyNode-specific keys, pass remaining to super - const { radius: _r, fill: _f, ...nodeOptions } = providedOptions ?? {}; + const options = optionize()( + { radius: 8, cursor: "default" }, + providedOptions, + ); + const { radius, fill, ...nodeOptions } = options; const body = new Circle(radius, { fill }); - super({ children: [body], cursor: "default", ...nodeOptions }); + super({ ...nodeOptions, children: [body] }); - positionProperty.link((pos) => { - const viewPos = mvt.modelToViewPosition(pos); - this.translation = viewPos; + Multilink.multilink([positionProperty, mvtProperty], (pos, mvt) => { + this.translation = mvt.modelToViewPosition(pos); }); } } diff --git a/src/configurations/model/ConfigurationsModel.ts b/src/configurations/model/ConfigurationsModel.ts index fa6ea0b..68d1422 100644 --- a/src/configurations/model/ConfigurationsModel.ts +++ b/src/configurations/model/ConfigurationsModel.ts @@ -1,5 +1,5 @@ import type { TReadOnlyProperty } from "scenerystack/axon"; -import { BooleanProperty, DerivedProperty, EnumerationProperty, NumberProperty, Property } from "scenerystack/axon"; +import { BooleanProperty, DerivedProperty, EnumerationProperty, NumberProperty } from "scenerystack/axon"; import { Range, Vector2 } from "scenerystack/dot"; import type { TModel } from "scenerystack/joist"; import { TimeModel } from "../../common/TimeModel.js"; @@ -14,6 +14,18 @@ function mod2pi(x: number): number { return ((x % TWO_PI) + TWO_PI) % TWO_PI; } +// Idle between animated moves; slewing eases the clock toward a target event; +// countingDown pauses play at an event for pauseTimeProperty seconds before resuming. +type AnimationState = "idle" | "slewing" | "countingDown"; + +type SystemProperties = { + readonly synodicPeriod: number; + readonly cycleOffset: number; + // [0]: 0, [1]: t_q, [2]: T_syn/2, [3]: T_syn − t_q + readonly eventTimesList: readonly [number, number, number, number]; + readonly eventNames: readonly string[]; +}; + export class ConfigurationsModel implements TModel { // ── Composed timing model ────────────────────────────────────────────────── public readonly timer: TimeModel; @@ -29,13 +41,12 @@ export class ConfigurationsModel implements TModel { // ── Dynamic time ─────────────────────────────────────────────────────────── public readonly timeProperty: NumberProperty; // years - // ── Synodic / event schedule ─────────────────────────────────────────────── - public readonly synodicPeriodProperty: NumberProperty; - public readonly cycleOffsetProperty: NumberProperty; - // [0]: 0, [1]: t_q, [2]: T_syn/2, [3]: T_syn − t_q - public readonly eventTimesListProperty: Property; - public readonly eventNamesProperty: Property; - public readonly currentCycleNumberProperty: NumberProperty; + // ── Synodic / event schedule (derived from the orbital parameters above) ─── + public readonly synodicPeriodProperty: TReadOnlyProperty; + public readonly cycleOffsetProperty: TReadOnlyProperty; + public readonly eventTimesListProperty: TReadOnlyProperty; + public readonly eventNamesProperty: TReadOnlyProperty; + public readonly currentCycleNumberProperty: TReadOnlyProperty; // ── Event navigation ─────────────────────────────────────────────────────── public readonly lockedOnEventProperty: BooleanProperty; @@ -69,17 +80,15 @@ export class ConfigurationsModel implements TModel { private nextCycleNumber = 0; private nextEventTime = 0; - // ── Private slew state ───────────────────────────────────────────────────── - private slewActive = false; + // ── Private animation state ──────────────────────────────────────────────── + private animationState: AnimationState = "idle"; private slewElapsed = 0; private readonly slewDuration = 0.65; // seconds private slewStartModelTime = 0; private slewDeltaModelTime = 0; private slewTargetCycle = 0; private slewTargetEvent = 0; - - // ── Private countdown state ──────────────────────────────────────────────── - private countdownElapsed = -1; // -1 = idle + private countdownElapsed = 0; public constructor() { this.timer = new TimeModel(false); @@ -94,12 +103,6 @@ export class ConfigurationsModel implements TModel { this.timeProperty = new NumberProperty(0); - this.synodicPeriodProperty = new NumberProperty(1); - this.cycleOffsetProperty = new NumberProperty(0); - this.eventTimesListProperty = new Property([0, 0, 0, 0]); - this.eventNamesProperty = new Property([]); - this.currentCycleNumberProperty = new NumberProperty(0, { numberType: "Integer" }); - this.lockedOnEventProperty = new BooleanProperty(false); this.lockedEventIndexProperty = new NumberProperty(-1, { range: new Range(-1, 3), @@ -158,6 +161,30 @@ export class ConfigurationsModel implements TModel { return ""; }); + // ── Synodic / event schedule — recomputed automatically whenever the + // orbital parameters change, instead of an imperatively-called method + // that every mutation site has to remember to invoke. ─────────────────── + const systemPropertiesProperty: TReadOnlyProperty = new DerivedProperty( + [ + this.semimajorAxis1Property, + this.semimajorAxis2Property, + this.period1Property, + this.period2Property, + this.epochAngle1Property, + this.epochAngle2Property, + ] as const, + (a1, a2, p1, p2, epoch1, epoch2) => ConfigurationsModel.computeSystemProperties(a1, a2, p1, p2, epoch1, epoch2), + ); + + this.synodicPeriodProperty = new DerivedProperty([systemPropertiesProperty], (sp) => sp.synodicPeriod); + this.cycleOffsetProperty = new DerivedProperty([systemPropertiesProperty], (sp) => sp.cycleOffset); + this.eventTimesListProperty = new DerivedProperty([systemPropertiesProperty], (sp) => sp.eventTimesList); + this.eventNamesProperty = new DerivedProperty([systemPropertiesProperty], (sp) => sp.eventNames); + this.currentCycleNumberProperty = new DerivedProperty( + [systemPropertiesProperty, this.timeProperty] as const, + (sp, time) => Math.floor((time - sp.cycleOffset) / sp.synodicPeriod), + ); + // ── Current configuration name ──────────────────────────────────────── this.currentConfigurationProperty = new DerivedProperty( [ @@ -174,8 +201,6 @@ export class ConfigurationsModel implements TModel { }, ); - // Initialize system properties - this.calculateSystemProperties(); this.setTime(0); } @@ -197,56 +222,44 @@ export class ConfigurationsModel implements TModel { return elongValue; } - private calculateSystemProperties(): void { - const a1 = this.semimajorAxis1Property.value; - const a2 = this.semimajorAxis2Property.value; - const p1 = this.period1Property.value; - const p2 = this.period2Property.value; - const epoch1 = this.epochAngle1Property.value; - const epoch2 = this.epochAngle2Property.value; - - let innerPeriod: number; - let outerPeriod: number; - let innerEpoch: number; - let outerEpoch: number; - let eventNames: readonly string[]; - - if (a1 < a2) { - innerPeriod = p1; - outerPeriod = p2; - innerEpoch = epoch1; - outerEpoch = epoch2; - eventNames = ["opposition", "quadrature (eastern)", "conjunction", "quadrature (western)"]; - const synodicPeriod = 1 / (1 / innerPeriod - 1 / outerPeriod); - const omegaSyn = TWO_PI / synodicPeriod; - const cycleOffset = mod2pi(outerEpoch - innerEpoch) / omegaSyn; - const tQ = Math.acos(a1 / a2) / omegaSyn; - const eventTimesList: [number, number, number, number] = [0, tQ, synodicPeriod / 2, synodicPeriod - tQ]; - this.synodicPeriodProperty.value = synodicPeriod; - this.cycleOffsetProperty.value = cycleOffset; - this.eventTimesListProperty.value = eventTimesList; - this.eventNamesProperty.value = eventNames; - } else { - innerPeriod = p2; - outerPeriod = p1; - innerEpoch = epoch2; - outerEpoch = epoch1; - eventNames = [ - "inferior conjunction", - "greatest elongation (western)", - "superior conjunction", - "greatest elongation (eastern)", - ]; - const synodicPeriod = 1 / (1 / innerPeriod - 1 / outerPeriod); - const omegaSyn = TWO_PI / synodicPeriod; - const cycleOffset = mod2pi(outerEpoch - innerEpoch) / omegaSyn; - const tQ = Math.acos(a2 / a1) / omegaSyn; - const eventTimesList: [number, number, number, number] = [0, tQ, synodicPeriod / 2, synodicPeriod - tQ]; - this.synodicPeriodProperty.value = synodicPeriod; - this.cycleOffsetProperty.value = cycleOffset; - this.eventTimesListProperty.value = eventTimesList; - this.eventNamesProperty.value = eventNames; - } + /** + * The four synodic events (opposition/conjunction pair for a superior + * configuration, or inferior/superior conjunction pair for an inferior one) + * depend only on which orbit is inner and which is outer — not on which + * planet id (1 or 2) happens to be inner, so both cases share one formula. + */ + private static computeSystemProperties( + a1: number, + a2: number, + p1: number, + p2: number, + epoch1: number, + epoch2: number, + ): SystemProperties { + const isPlanet1Inner = a1 < a2; + const innerA = isPlanet1Inner ? a1 : a2; + const outerA = isPlanet1Inner ? a2 : a1; + const innerPeriod = isPlanet1Inner ? p1 : p2; + const outerPeriod = isPlanet1Inner ? p2 : p1; + const innerEpoch = isPlanet1Inner ? epoch1 : epoch2; + const outerEpoch = isPlanet1Inner ? epoch2 : epoch1; + + const eventNames: readonly string[] = isPlanet1Inner + ? ["opposition", "quadrature (eastern)", "conjunction", "quadrature (western)"] + : [ + "inferior conjunction", + "greatest elongation (western)", + "superior conjunction", + "greatest elongation (eastern)", + ]; + + const synodicPeriod = 1 / (1 / innerPeriod - 1 / outerPeriod); + const omegaSyn = TWO_PI / synodicPeriod; + const cycleOffset = mod2pi(outerEpoch - innerEpoch) / omegaSyn; + const tQ = Math.acos(innerA / outerA) / omegaSyn; + const eventTimesList: readonly [number, number, number, number] = [0, tQ, synodicPeriod / 2, synodicPeriod - tQ]; + + return { synodicPeriod, cycleOffset, eventTimesList, eventNames }; } // ── Public API ───────────────────────────────────────────────────────────── @@ -275,8 +288,6 @@ export class ConfigurationsModel implements TModel { this.period2Property.value = newPeriod; } - this.calculateSystemProperties(); - // AS behavior: when locked on an even event (opposition or conjunction), re-snap if ( this.lockedOnEventProperty.value && @@ -293,34 +304,44 @@ export class ConfigurationsModel implements TModel { return true; } - public setTime(newTime: number): void { - this.timeProperty.value = newTime; + /** + * Where a given model time falls in the event schedule: which (cycle, + * event) is the next upcoming event at or after that time. Shared by + * setTime() and findSnappedEvent() so they can't drift apart. + */ + private locateEvent(time: number): { cycle: number; event: number } { const cycleOffset = this.cycleOffsetProperty.value; const synodic = this.synodicPeriodProperty.value; const eventTimes = this.eventTimesListProperty.value; - const currentCycle = Math.floor((newTime - cycleOffset) / synodic); - this.currentCycleNumberProperty.value = currentCycle; - - let timeInCycle = newTime - cycleOffset - currentCycle * synodic; + const cycle = Math.floor((time - cycleOffset) / synodic); + let timeInCycle = time - cycleOffset - cycle * synodic; if (timeInCycle < 0) { timeInCycle = 0; } - let nextEvt = 0; - while (nextEvt < 4 && timeInCycle >= (eventTimes[nextEvt] ?? 0)) { - nextEvt++; + let event = 0; + while (event < 4 && timeInCycle >= (eventTimes[event] ?? 0)) { + event++; } - if (nextEvt < 4) { - this.nextEventNumber = nextEvt; - this.nextCycleNumber = currentCycle; - } else { - this.nextEventNumber = 0; - this.nextCycleNumber = currentCycle + 1; + if (event < 4) { + return { cycle, event }; } + return { cycle: cycle + 1, event: 0 }; + } + + public setTime(newTime: number): void { + this.timeProperty.value = newTime; + + const next = this.locateEvent(newTime); + this.nextCycleNumber = next.cycle; + this.nextEventNumber = next.event; + this.nextEventTime = + this.cycleOffsetProperty.value + + next.cycle * this.synodicPeriodProperty.value + + (this.eventTimesListProperty.value[next.event] ?? 0); - this.nextEventTime = cycleOffset + this.nextCycleNumber * synodic + (eventTimes[this.nextEventNumber] ?? 0); this.lockedOnEventProperty.value = false; this.lockedEventIndexProperty.value = -1; } @@ -331,7 +352,6 @@ export class ConfigurationsModel implements TModel { const eventTimes = this.eventTimesListProperty.value; this.timeProperty.value = cycleOffset + cycleNumber * synodic + (eventTimes[eventNumber] ?? 0); - this.currentCycleNumberProperty.value = cycleNumber; this.nextEventNumber = eventNumber + 1; this.nextCycleNumber = cycleNumber; @@ -345,6 +365,11 @@ export class ConfigurationsModel implements TModel { this.lockedEventIndexProperty.value = noLock ? -1 : eventNumber; } + /** + * The nearest event (previous or next) to newTime, or null if none is + * within angleThreshold. "Previous" is derived from the same (cycle, + * event) pair locateEvent() returns for "next," rather than re-scanning. + */ private findSnappedEvent( newTime: number, period: number, @@ -354,30 +379,14 @@ export class ConfigurationsModel implements TModel { const synodic = this.synodicPeriodProperty.value; const eventTimes = this.eventTimesListProperty.value; - const cycle = Math.floor((newTime - cycleOffset) / synodic); - let timeInCycle = newTime - cycleOffset - cycle * synodic; - if (timeInCycle < 0) { - timeInCycle = 0; - } - - let nextEvt = 0; - while (nextEvt < 4 && timeInCycle >= (eventTimes[nextEvt] ?? 0)) { - nextEvt++; - } - - const prevEvt = nextEvt - 1; - let nextCycleFinal = cycle; - let nextEvtFinal = nextEvt; - if (nextEvt >= 4) { - nextEvtFinal = 0; - nextCycleFinal = cycle + 1; - } + const next = this.locateEvent(newTime); + // event 0 only comes from locateEvent()'s cycle-boundary rollover (event + // times always start at 0, so a scan can never land on event 0 otherwise). + const prevEvent = next.event === 0 ? 3 : next.event - 1; + const prevCycle = next.event === 0 ? next.cycle - 1 : next.cycle; - const prevTime = - prevEvt >= 0 - ? cycleOffset + cycle * synodic + (eventTimes[prevEvt] ?? 0) - : cycleOffset + (cycle - 1) * synodic + (eventTimes[3] ?? 0); - const nextTime = cycleOffset + nextCycleFinal * synodic + (eventTimes[nextEvtFinal] ?? 0); + const prevTime = cycleOffset + prevCycle * synodic + (eventTimes[prevEvent] ?? 0); + const nextTime = cycleOffset + next.cycle * synodic + (eventTimes[next.event] ?? 0); const prevDeltaAngle = (Math.abs(newTime - prevTime) * TWO_PI) / period; const nextDeltaAngle = (Math.abs(nextTime - newTime) * TWO_PI) / period; @@ -387,11 +396,9 @@ export class ConfigurationsModel implements TModel { return null; } if (minDelta === nextDeltaAngle) { - return { cycle: nextCycleFinal, event: nextEvtFinal }; + return { cycle: next.cycle, event: next.event }; } - const effectivePrevEvt = prevEvt >= 0 ? prevEvt : 3; - const effectivePrevCycle = prevEvt >= 0 ? cycle : cycle - 1; - return { cycle: effectivePrevCycle, event: effectivePrevEvt }; + return { cycle: prevCycle, event: prevEvent }; } public setTimeByPlanetAngle(id: 1 | 2, newAngle: number, snapToEvents: boolean, angleThreshold: number): void { @@ -463,7 +470,6 @@ export class ConfigurationsModel implements TModel { const snappedAngle = eventAngles[snappedEvent] as number; const newEpochAdj = mod2pi(snappedAngle - (TWO_PI * this.timeProperty.value) / period); epochProp.value = newEpochAdj; - this.calculateSystemProperties(); const eventTimes = this.eventTimesListProperty.value; const snappedEvtTime = eventTimes[snappedEvent] as number; @@ -478,7 +484,6 @@ export class ConfigurationsModel implements TModel { const newEpoch = mod2pi(newAngle - (TWO_PI * this.timeProperty.value) / period); epochProp.value = newEpoch; - this.calculateSystemProperties(); this.setTime(this.timeProperty.value); } @@ -492,28 +497,26 @@ export class ConfigurationsModel implements TModel { return; } - this.slewActive = true; + this.animationState = "slewing"; this.slewElapsed = 0; this.slewStartModelTime = this.timeProperty.value; this.slewDeltaModelTime = targetTime - this.slewStartModelTime; this.slewTargetCycle = cycleNumber; this.slewTargetEvent = eventNumber; this.timer.isPlayingProperty.value = false; - this.countdownElapsed = -1; this.countdownRemainingProperty.value = 0; } public resetTime(): void { - this.slewActive = false; - this.countdownElapsed = -1; + this.animationState = "idle"; this.countdownRemainingProperty.value = 0; this.epochAngle1Property.value = 0; this.epochAngle2Property.value = 0; - this.calculateSystemProperties(); this.setTime(0); } private startCountdown(): void { + this.animationState = "countingDown"; this.countdownElapsed = 0; this.countdownRemainingProperty.value = this.pauseTimeProperty.value; } @@ -525,7 +528,7 @@ export class ConfigurationsModel implements TModel { const ease = 1 - (1 - u) ** 3; this.timeProperty.value = this.slewStartModelTime + ease * this.slewDeltaModelTime; } else { - this.slewActive = false; + this.animationState = "idle"; this.setTimeByCycleAndEventNumbers(this.slewTargetCycle, this.slewTargetEvent); } } @@ -534,7 +537,7 @@ export class ConfigurationsModel implements TModel { this.countdownElapsed += dt; const remaining = this.pauseTimeProperty.value - this.countdownElapsed; if (remaining <= 0) { - this.countdownElapsed = -1; + this.animationState = "idle"; this.countdownRemainingProperty.value = 0; this.timer.isPlayingProperty.value = true; } else { @@ -543,11 +546,11 @@ export class ConfigurationsModel implements TModel { } public step(dt: number): void { - if (this.slewActive) { + if (this.animationState === "slewing") { this.advanceSlew(dt); return; } - if (this.countdownElapsed >= 0 && this.countdownRemainingProperty.value > 0) { + if (this.animationState === "countingDown") { this.advanceCountdown(dt); return; } @@ -591,8 +594,7 @@ export class ConfigurationsModel implements TModel { public reset(): void { this.timer.reset(); - this.slewActive = false; - this.countdownElapsed = -1; + this.animationState = "idle"; // Reset to defaults (a1=1 Earth, a2=2.4) this.semimajorAxis1Property.value = 1; @@ -611,7 +613,6 @@ export class ConfigurationsModel implements TModel { this.pauseTimeProperty.reset(); this.countdownRemainingProperty.reset(); - this.calculateSystemProperties(); this.setTime(0); } } diff --git a/src/configurations/model/ConfigurationsPlanet.ts b/src/configurations/model/ConfigurationsPlanet.ts index 14e1d2a..387c9d3 100644 --- a/src/configurations/model/ConfigurationsPlanet.ts +++ b/src/configurations/model/ConfigurationsPlanet.ts @@ -2,6 +2,8 @@ import SolarSystemModelsNamespace from "../../SolarSystemModelsNamespace.js"; export type PlanetPresetKey = "mercury" | "venus" | "earth" | "mars" | "jupiter" | "saturn"; +// Semimajor axis presets (AU) — the single source of truth for these radii; +// don't duplicate them in SolarSystemModelsConstants.ts. export const PLANET_PRESETS: Record = { mercury: 0.39, venus: 0.72, diff --git a/src/configurations/view/ConfigurationsElongationIndicator.ts b/src/configurations/view/ConfigurationsElongationIndicator.ts index e0e1797..8cba552 100644 --- a/src/configurations/view/ConfigurationsElongationIndicator.ts +++ b/src/configurations/view/ConfigurationsElongationIndicator.ts @@ -1,3 +1,4 @@ +import type { TReadOnlyProperty } from "scenerystack/axon"; import { Multilink } from "scenerystack/axon"; import { Vector2 } from "scenerystack/dot"; import { Shape } from "scenerystack/kite"; @@ -9,19 +10,14 @@ import { CONFIGURATIONS_ELONGATION_ARC_RADIUS } from "../../SolarSystemModelsCon import type { ConfigurationsModel } from "../model/ConfigurationsModel.js"; export class ConfigurationsElongationIndicator extends Node { - private mvt: ModelViewTransform2; - private readonly model: ConfigurationsModel; private readonly sunArrow: ArrowNode; private readonly planetArrow: ArrowNode; private readonly arcPath: Path; private readonly elongLabel: Text; - public constructor(model: ConfigurationsModel, mvt: ModelViewTransform2) { + public constructor(model: ConfigurationsModel, mvtProperty: TReadOnlyProperty) { super({ visibleProperty: model.showElongationAngleProperty }); - this.model = model; - this.mvt = mvt; - this.sunArrow = new ArrowNode(0, 0, 1, 0, { stroke: null, fill: SolarSystemModelsColors.elongationColorProperty, @@ -50,28 +46,27 @@ export class ConfigurationsElongationIndicator extends Node { this.addChild(this.planetArrow); this.addChild(this.elongLabel); + // Combining pos1/pos2/elongation with mvtProperty here (rather than the + // screen view manually pushing an update after rebuilding the transform) + // means this node always redraws itself consistently, whichever of its + // dependencies changed. Multilink.multilink( - [model.pos1Property, model.pos2Property, model.elongationDegProperty, model.elongationLabelProperty] as const, - (p1, p2, elongDeg, elongLabel_) => this.update(p1, p2, elongDeg, elongLabel_), - ); - } - - /** Called by the screen view when the orbit scale changes and a new transform is built. */ - public setModelViewTransform(mvt: ModelViewTransform2): void { - this.mvt = mvt; - this.update( - this.model.pos1Property.value, - this.model.pos2Property.value, - this.model.elongationDegProperty.value, - this.model.elongationLabelProperty.value, + [ + model.pos1Property, + model.pos2Property, + model.elongationDegProperty, + model.elongationLabelProperty, + mvtProperty, + ] as const, + (p1, p2, elongDeg, elongLabel_, mvt) => this.update(p1, p2, elongDeg, elongLabel_, mvt), ); } - private update(p1: Vector2, p2: Vector2, elongDeg: number, elongLabel_: string): void { + private update(p1: Vector2, p2: Vector2, elongDeg: number, elongLabel_: string, mvt: ModelViewTransform2): void { // Convert model positions to view - const vp1 = this.mvt.modelToViewPosition(p1); - const vp2 = this.mvt.modelToViewPosition(p2); - const vSun = this.mvt.modelToViewPosition(Vector2.ZERO); + const vp1 = mvt.modelToViewPosition(p1); + const vp2 = mvt.modelToViewPosition(p2); + const vSun = mvt.modelToViewPosition(Vector2.ZERO); // Direction from p1 toward Sun (view) const sunDir = Math.atan2(vSun.y - vp1.y, vSun.x - vp1.x); diff --git a/src/configurations/view/ConfigurationsScreenView.ts b/src/configurations/view/ConfigurationsScreenView.ts index 0e0177d..403be8e 100644 --- a/src/configurations/view/ConfigurationsScreenView.ts +++ b/src/configurations/view/ConfigurationsScreenView.ts @@ -1,4 +1,5 @@ -import { Multilink } from "scenerystack/axon"; +import type { TReadOnlyProperty } from "scenerystack/axon"; +import { DerivedProperty, Multilink } from "scenerystack/axon"; import { Vector2 } from "scenerystack/dot"; import { Shape } from "scenerystack/kite"; import { ModelViewTransform2 } from "scenerystack/phetcommon"; @@ -41,8 +42,8 @@ function buildMvt(a1: number, a2: number): ModelViewTransform2 { export class ConfigurationsScreenView extends ScreenView { private readonly model: ConfigurationsModel; - // mvt changes when orbital radii change - private mvt: ModelViewTransform2; + // Rebuilt whenever the orbital radii change, so its scale always fits both orbits. + private readonly mvtProperty: TReadOnlyProperty; public constructor(model: ConfigurationsModel, options?: ScreenViewOptions) { super({ @@ -51,7 +52,10 @@ export class ConfigurationsScreenView extends ScreenView { }); this.model = model; - this.mvt = buildMvt(model.semimajorAxis1Property.value, model.semimajorAxis2Property.value); + this.mvtProperty = new DerivedProperty( + [model.semimajorAxis1Property, model.semimajorAxis2Property] as const, + (a1, a2) => buildMvt(a1, a2), + ); const a11y = StringManager.getInstance().getConfigurationsA11yStrings(); const s = StringManager.getInstance().getConfigurationsStrings(); @@ -101,20 +105,18 @@ export class ConfigurationsScreenView extends ScreenView { this.addChild(orbitLabel2); // ── Elongation indicator (Phase 7) ────────────────────────────────────── - const elongationIndicator = new ConfigurationsElongationIndicator(model, this.mvt); + const elongationIndicator = new ConfigurationsElongationIndicator(model, this.mvtProperty); this.addChild(elongationIndicator); // ── Sun at origin ─────────────────────────────────────────────────────── const sunNode = new Circle(12, { fill: SolarSystemModelsColors.sunColorProperty }); this.addChild(sunNode); - - const updateSunPos = () => { - sunNode.translation = this.mvt.modelToViewPosition(Vector2.ZERO); - }; - updateSunPos(); + this.mvtProperty.link((mvt) => { + sunNode.translation = mvt.modelToViewPosition(Vector2.ZERO); + }); // ── Observer planet (blue) ────────────────────────────────────────────── - const observerNode = new CelestialBodyNode(model.pos1Property, this.mvt, { + const observerNode = new CelestialBodyNode(model.pos1Property, this.mvtProperty, { radius: 8, fill: SolarSystemModelsColors.observerPlanetColorProperty, cursor: "pointer", @@ -126,7 +128,7 @@ export class ConfigurationsScreenView extends ScreenView { this.addChild(observerNode); // ── Target planet (grey) ──────────────────────────────────────────────── - const targetNode = new CelestialBodyNode(model.pos2Property, this.mvt, { + const targetNode = new CelestialBodyNode(model.pos2Property, this.mvtProperty, { radius: 8, fill: SolarSystemModelsColors.targetPlanetColorProperty, cursor: "pointer", @@ -147,7 +149,7 @@ export class ConfigurationsScreenView extends ScreenView { }, drag: (event, listener) => { const shiftKey = (event.domEvent as MouseEvent | null)?.shiftKey ?? false; - const modelPos = this.mvt.viewToModelPosition(listener.modelPoint); + const modelPos = this.mvtProperty.value.viewToModelPosition(listener.modelPoint); const angle = Math.atan2(modelPos.y, modelPos.x); const snap = model.snapToEventsProperty.value; if (shiftKey) { @@ -162,29 +164,20 @@ export class ConfigurationsScreenView extends ScreenView { makePlanetDrag(1, observerNode); makePlanetDrag(2, targetNode); - // ── Update orbit circles + labels when radii change ───────────────────── + // ── Update orbit circles + labels when the transform or radii change ──── + // observerNode/targetNode/elongationIndicator/sunNode each keep themselves + // in sync via their own link on mvtProperty above — this only needs to + // redraw the shapes that live directly in this view. const updateOrbits = () => { + const mvt = this.mvtProperty.value; const a1 = model.semimajorAxis1Property.value; const a2 = model.semimajorAxis2Property.value; - // Mutate the existing mvt's matrix in place (rather than replacing the - // object) so CelestialBodyNode's internal closure — captured once at - // construction — keeps reading the current transform on every future - // position update, not just the one right after this call. - this.mvt.setMatrix(buildMvt(a1, a2).matrix); - - // pos1Property/pos2Property may have already reacted to the axis change - // (and pushed stale-scale positions) before this listener ran, so - // explicitly re-sync the planet nodes and elongation indicator now. - observerNode.translation = this.mvt.modelToViewPosition(model.pos1Property.value); - targetNode.translation = this.mvt.modelToViewPosition(model.pos2Property.value); - elongationIndicator.setModelViewTransform(this.mvt); - - const center = this.mvt.modelToViewPosition(Vector2.ZERO); - - const r1 = this.mvt.modelToViewDeltaX(a1); + const center = mvt.modelToViewPosition(Vector2.ZERO); + + const r1 = mvt.modelToViewDeltaX(a1); orbit1Circle.shape = Shape.circle(center.x, center.y, r1); - const r2 = this.mvt.modelToViewDeltaX(a2); + const r2 = mvt.modelToViewDeltaX(a2); orbit2Circle.shape = Shape.circle(center.x, center.y, r2); // Labels at top of orbit circles @@ -196,14 +189,9 @@ export class ConfigurationsScreenView extends ScreenView { orbitLabel2.string = `${a2.toFixed(2)} ${au}`; orbitLabel2.centerX = center.x; orbitLabel2.bottom = center.y - r2 - 4; - - // Update planet node positions to use new MVT - updateSunPos(); }; - Multilink.multilink([model.semimajorAxis1Property, model.semimajorAxis2Property, s.auStringProperty] as const, () => - updateOrbits(), - ); + Multilink.multilink([this.mvtProperty, s.auStringProperty] as const, () => updateOrbits()); // ── Zodiac strip at bottom ────────────────────────────────────────────── const zodiacStrip = new ConfigurationsZodiacStrip(model); @@ -253,9 +241,6 @@ export class ConfigurationsScreenView extends ScreenView { pdomOrder: [controlPanel, displayPanel, timeReadout, observerNode, targetNode, resetAllButton], }), ); - - // Trigger initial orbit draw - updateOrbits(); } public reset(): void { diff --git a/src/ptolemaic/model/PtolemaicModel.ts b/src/ptolemaic/model/PtolemaicModel.ts index 76698bf..6868917 100644 --- a/src/ptolemaic/model/PtolemaicModel.ts +++ b/src/ptolemaic/model/PtolemaicModel.ts @@ -17,6 +17,12 @@ import { import type { PlanetPresetKey } from "./PtolemaicPlanet.js"; import { PLANET_PRESETS, PlanetType, PRESET_KEYS } from "./PtolemaicPlanet.js"; +const TWO_PI = 2 * Math.PI; + +function mod2pi(x: number): number { + return ((x % TWO_PI) + TWO_PI) % TWO_PI; +} + type MemorySnapshot = { epicycleSize: number; eccentricity: number; @@ -60,7 +66,6 @@ export class PtolemaicModel implements TModel { public readonly planetPositionProperty: TReadOnlyProperty; public readonly sunPositionProperty: TReadOnlyProperty; public readonly eclipticLongitudeProperty: TReadOnlyProperty; - public readonly sunLongitudeProperty: TReadOnlyProperty; private memory: MemorySnapshot | null = null; @@ -112,15 +117,15 @@ export class PtolemaicModel implements TModel { this.anomalyProperty, ] as const; - this.deferentCenterProperty = new DerivedProperty(geomDeps, (_re, ecc, apogDeg, _type, _sun, _an) => { - const ap = (apogDeg * Math.PI) / 180; - return new Vector2(ecc * Math.cos(ap), ecc * Math.sin(ap)); - }); + this.deferentCenterProperty = new DerivedProperty(geomDeps, (_re, ecc, apogDeg) => + PtolemaicModel.computeDeferentCenter(ecc, apogDeg), + ); - this.equantPositionProperty = new DerivedProperty(geomDeps, (_re, ecc, apogDeg, _type, _sun, _an) => { - const ap = (apogDeg * Math.PI) / 180; - return new Vector2(2 * ecc * Math.cos(ap), 2 * ecc * Math.sin(ap)); - }); + // The equant is the deferent center's mirror point across the Earth-centered + // deferent circle: twice as far from Earth, along the same apogee direction. + this.equantPositionProperty = new DerivedProperty(geomDeps, (_re, ecc, apogDeg) => + PtolemaicModel.computeDeferentCenter(ecc, apogDeg).timesScalar(2), + ); this.epicycleCenterProperty = new DerivedProperty(geomDeps, (_re, ecc, apogDeg, type, sun, anomaly) => { return PtolemaicModel.computeEpicycleCenter(ecc, apogDeg, type, sun, anomaly); @@ -141,8 +146,6 @@ export class PtolemaicModel implements TModel { Math.atan2(pos.y, pos.x), ); - this.sunLongitudeProperty = new DerivedProperty([this.sunAngleProperty], (sun) => sun); - // Apply Mars preset to keep presetKeyProperty in sync this.presetKeyProperty.lazyLink((idx) => { const key = PRESET_KEYS[idx]; @@ -154,6 +157,11 @@ export class PtolemaicModel implements TModel { // ── Geometry helpers ─────────────────────────────────────────────────────── + private static computeDeferentCenter(ecc: number, apogDeg: number): Vector2 { + const ap = (apogDeg * Math.PI) / 180; + return new Vector2(ecc * Math.cos(ap), ecc * Math.sin(ap)); + } + private static computeEpicycleCenter( ecc: number, apogDeg: number, @@ -162,7 +170,7 @@ export class PtolemaicModel implements TModel { anomaly: number, ): Vector2 { const ap = (apogDeg * Math.PI) / 180; - const deferentCenter = new Vector2(ecc * Math.cos(ap), ecc * Math.sin(ap)); + const deferentCenter = PtolemaicModel.computeDeferentCenter(ecc, apogDeg); const equantAngle = ap; const equantDistance = ecc; @@ -234,8 +242,10 @@ export class PtolemaicModel implements TModel { const anomalyRate = (this.motionRateProperty.value * Math.PI) / 180; this.ptolemaicTimeProperty.value += dtDays; - this.sunAngleProperty.value += sunRate * dtDays; - this.anomalyProperty.value += anomalyRate * dtDays; + // Wrapped to [0, 2π) so these angles don't lose float precision over a + // long play session — matches ConfigurationsModel's angle convention. + this.sunAngleProperty.value = mod2pi(this.sunAngleProperty.value + sunRate * dtDays); + this.anomalyProperty.value = mod2pi(this.anomalyProperty.value + anomalyRate * dtDays); } public reset(): void { diff --git a/src/ptolemaic/view/PtolemaicPathTrail.ts b/src/ptolemaic/view/PtolemaicPathTrail.ts index 2cee1e0..be27665 100644 --- a/src/ptolemaic/view/PtolemaicPathTrail.ts +++ b/src/ptolemaic/view/PtolemaicPathTrail.ts @@ -3,7 +3,7 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; import { Node, Path } from "scenerystack/scenery"; import SolarSystemModelsColors from "../../SolarSystemModelsColors.js"; -import { DAYS_PER_YEAR } from "../../SolarSystemModelsConstants.js"; +import { PATH_DURATION_RANGE } from "../../SolarSystemModelsConstants.js"; import type { PtolemaicModel } from "../model/PtolemaicModel.js"; const MAX_POINTS = 2000; @@ -41,11 +41,10 @@ export class PtolemaicPathTrail extends Node { const viewPos = this.mvt.modelToViewPosition(modelPos); this.points.push(viewPos); - // Cap buffer: keep only as many points as correspond to pathDuration years - const maxPts = Math.max( - 1, - Math.round((model.pathDurationProperty.value * DAYS_PER_YEAR * MAX_POINTS) / (5 * 365.25)), - ); + // Cap buffer: keep only as many points as correspond to pathDuration years, + // scaled against the longest selectable path duration (which fills the + // whole MAX_POINTS buffer). + const maxPts = Math.max(1, Math.round((model.pathDurationProperty.value * MAX_POINTS) / PATH_DURATION_RANGE.max)); if (this.points.length > MAX_POINTS || this.points.length > maxPts) { this.points.shift(); } diff --git a/src/ptolemaic/view/PtolemaicScreenView.ts b/src/ptolemaic/view/PtolemaicScreenView.ts index b6e8210..5575d2d 100644 --- a/src/ptolemaic/view/PtolemaicScreenView.ts +++ b/src/ptolemaic/view/PtolemaicScreenView.ts @@ -1,4 +1,4 @@ -import { Multilink } from "scenerystack/axon"; +import { Multilink, Property } from "scenerystack/axon"; import { Vector2 } from "scenerystack/dot"; import { Shape } from "scenerystack/kite"; import { ModelViewTransform2 } from "scenerystack/phetcommon"; @@ -70,6 +70,9 @@ export class PtolemaicScreenView extends ScreenView { ); const mvt = this.mvt; + // Ptolemaic's transform never changes (no adjustable orbit radii on this + // screen), so CelestialBodyNode's mvtProperty is a constant Property. + const mvtProperty = new Property(mvt); // ── Background ───────────────────────────────────────────────────────── const background = new Rectangle(0, 0, this.layoutBounds.width, this.layoutBounds.height, { @@ -248,7 +251,7 @@ export class PtolemaicScreenView extends ScreenView { this.addChild(earthNode); // ── Sun node (draggable) ─────────────────────────────────────────────── - const sunNode = new CelestialBodyNode(model.sunPositionProperty, mvt, { + const sunNode = new CelestialBodyNode(model.sunPositionProperty, mvtProperty, { radius: 11, fill: SolarSystemModelsColors.sunColorProperty, cursor: "pointer", @@ -269,7 +272,7 @@ export class PtolemaicScreenView extends ScreenView { ); // ── Planet node ──────────────────────────────────────────────────────── - const planetNode = new CelestialBodyNode(model.planetPositionProperty, mvt, { + const planetNode = new CelestialBodyNode(model.planetPositionProperty, mvtProperty, { radius: 7, fill: SolarSystemModelsColors.planetColorProperty, });