diff --git a/CLAUDE.md b/CLAUDE.md index e10eae3..6f65561 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,8 +5,8 @@ Sim-specific context for AI assistants. General SceneryStack guidance: [OpenPhys ## Project A two-screen SceneryStack simulation porting the NAAP **Solar System Models** lab, -scaffolded from `TemplateSingleSim`. **Scaffolding only** — both screens are a -placeholder label + Reset All; no model/physics yet. +scaffolded from `TemplateSingleSim`. Both screens now have complete models and +fully wired views (not scaffolding). - **Ptolemaic System** (`src/ptolemaic/`) — port of the NAAP *Ptolemaic System Simulator* (`ptolemaic.swf`): the Earth-centered (geocentric) model with deferent + epicycle and the resulting view from Earth. - **Planetary Configurations** (`src/configurations/`) — port of the NAAP *Planetary Configurations Simulator* (`configurationsSimulator.swf`): the Sun-centered system and the configurations (opposition, conjunction, elongation) that Earth and another planet form. @@ -26,10 +26,10 @@ Shared code keeps the `SolarSystemModels` prefix; per-screen code uses the | `src/i18n/StringManager.ts` | Singleton localized string accessor; per-screen name + a11y getters | | `src/main.ts` | Entry point; registers both screens with the Sim | | `src/ptolemaic/PtolemaicScreen.ts` | `Screen` wrapper | -| `src/ptolemaic/model/PtolemaicModel.ts` | Ptolemaic screen state (scaffold) | +| `src/ptolemaic/model/PtolemaicModel.ts` | Ptolemaic screen state: deferent/epicycle geometry, presets, memory | | `src/ptolemaic/view/PtolemaicScreenView.ts` | Ptolemaic visuals, `screenSummaryContent` + `pdomOrder` | | `src/configurations/ConfigurationsScreen.ts` | `Screen` wrapper | -| `src/configurations/model/ConfigurationsModel.ts` | Configurations screen state (scaffold) | +| `src/configurations/model/ConfigurationsModel.ts` | Configurations screen state: orbits, synodic events, timeline | | `src/configurations/view/ConfigurationsScreenView.ts` | Configurations visuals, `screenSummaryContent` + `pdomOrder` | | `src/preferences/solarSystemModelsQueryParameters.ts` | `QueryStringMachine` parameters | | `scripts/decompile-flash.ts` | Extract ActionScript from the NAAP Flash `.swf` sources via JPEXS FFDec (→ `NAAP/decompiled/`) | diff --git a/src/SolarSystemModelsConstants.ts b/src/SolarSystemModelsConstants.ts index 841919c..2064238 100644 --- a/src/SolarSystemModelsConstants.ts +++ b/src/SolarSystemModelsConstants.ts @@ -12,6 +12,9 @@ export const ORBIT_VIEW_SCALE = 95; // px per model unit export const ORBIT_VIEW_CENTER_X = 295; // px — model origin maps here (x) export const ORBIT_VIEW_CENTER_Y = 300; // px — model origin maps here (y) export const ZODIAC_LABEL_RADIUS = 285; // px — zodiac sign label ring +export const ZODIAC_LABEL_MAX_WIDTH = 55; // px — max width of a zodiac sign label +export const ZODIAC_TICK_INNER_RADIUS = 250; // px — zodiac sign boundary tick, inner end +export const ZODIAC_TICK_OUTER_RADIUS = 270; // px — zodiac sign boundary tick, outer end export const ZODIAC_STRIP_HEIGHT = 80; // px — "view from Earth" strip height export const ZODIAC_STRIP_WIDTH = 600; // px — width matching AS factor 600/2π @@ -39,6 +42,8 @@ export const CONFIGURATIONS_ORBIT_CENTER_Y = 285; // px — Sun maps here (y) export const CONFIGURATIONS_ORBIT_MARGIN = 60; // px — margin around orbit area export const CONFIGURATIONS_TIMELINE_WIDTH = 210; // px 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) ─────────────────────────────────── @@ -59,6 +64,9 @@ SolarSystemModelsNamespace.register("SolarSystemModelsConstants", { ORBIT_VIEW_CENTER_X, ORBIT_VIEW_CENTER_Y, ZODIAC_LABEL_RADIUS, + ZODIAC_LABEL_MAX_WIDTH, + ZODIAC_TICK_INNER_RADIUS, + ZODIAC_TICK_OUTER_RADIUS, ZODIAC_STRIP_HEIGHT, ZODIAC_STRIP_WIDTH, PTOLEMAIC_DEFERENT_RADIUS, @@ -71,6 +79,8 @@ SolarSystemModelsNamespace.register("SolarSystemModelsConstants", { CONFIGURATIONS_ORBIT_MARGIN, CONFIGURATIONS_TIMELINE_WIDTH, CONFIGURATIONS_TIMELINE_HEIGHT, + CONFIGURATIONS_TIMELINE_CYCLE_HEIGHT, + CONFIGURATIONS_ELONGATION_ARC_RADIUS, EPICYCLE_SIZE_RANGE, ECCENTRICITY_RANGE, MOTION_RATE_RANGE, diff --git a/src/common/CelestialBodyNode.ts b/src/common/CelestialBodyNode.ts index 4b70669..7623c42 100644 --- a/src/common/CelestialBodyNode.ts +++ b/src/common/CelestialBodyNode.ts @@ -2,17 +2,16 @@ import type { TReadOnlyProperty } from "scenerystack/axon"; import type { Vector2 } from "scenerystack/dot"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; import type { NodeOptions, TPaint } from "scenerystack/scenery"; -import { Circle, Node, Text } from "scenerystack/scenery"; +import { Circle, Node } from "scenerystack/scenery"; export type CelestialBodyNodeOptions = { radius?: number; fill?: TPaint; - label?: string; } & NodeOptions; /** - * A Circle + optional label Text, 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 + * ModelViewTransform2. Used for Earth, Sun, planets, and markers. */ export class CelestialBodyNode extends Node { public constructor( @@ -22,26 +21,13 @@ export class CelestialBodyNode extends Node { ) { const radius = providedOptions?.radius ?? 8; const fill = providedOptions?.fill ?? "#ffffff"; - const label = providedOptions?.label; // Extract CelestialBodyNode-specific keys, pass remaining to super - const { radius: _r, fill: _f, label: _l, ...nodeOptions } = providedOptions ?? {}; + const { radius: _r, fill: _f, ...nodeOptions } = providedOptions ?? {}; const body = new Circle(radius, { fill }); - const children: Node[] = [body]; - if (label !== undefined) { - children.push( - new Text(label, { - font: "12px sans-serif", - fill: "#ffffff", - centerX: 0, - top: radius + 3, - }), - ); - } - - super({ children, cursor: "default", ...nodeOptions }); + super({ children: [body], cursor: "default", ...nodeOptions }); positionProperty.link((pos) => { const viewPos = mvt.modelToViewPosition(pos); diff --git a/src/common/ZodiacStripBackground.ts b/src/common/ZodiacStripBackground.ts new file mode 100644 index 0000000..0304348 --- /dev/null +++ b/src/common/ZodiacStripBackground.ts @@ -0,0 +1,47 @@ +import type { TReadOnlyProperty } from "scenerystack/axon"; +import { Node, Rectangle, Text } from "scenerystack/scenery"; +import { PhetFont } from "scenerystack/scenery-phet"; +import SolarSystemModelsColors from "../SolarSystemModelsColors.js"; + +/** Wrap x into [0, width) — shared by both zodiac strips' longitude→x mapping. */ +export function wrapToWidth(x: number, width: number): number { + return ((x % width) + width) % width; +} + +/** + * Shared "view from Earth" zodiac strip chrome: background band, 12 evenly + * spaced sign labels, and dividers between them. Screen-specific overlays + * (constellation art, sun/planet markers, elongation labels, ...) are added + * by the caller on top of this Node. + */ +export class ZodiacStripBackground extends Node { + public constructor(width: number, height: number, signStringProperties: readonly TReadOnlyProperty[]) { + super(); + + const band = new Rectangle(0, 0, width, height, { + fill: SolarSystemModelsColors.zodiacBandColorProperty, + stroke: SolarSystemModelsColors.zodiacBorderColorProperty, + lineWidth: 1, + }); + this.addChild(band); + + const segW = width / 12; + for (let i = 0; i < 12; i++) { + const label = new Text(signStringProperties[i]!, { + font: new PhetFont(9), + fill: SolarSystemModelsColors.zodiacLabelColorProperty, + maxWidth: segW - 4, + }); + label.centerX = (i + 0.5) * segW; + label.centerY = height * 0.25; + this.addChild(label); + + if (i > 0) { + const divider = new Rectangle(i * segW, 0, 1, height, { + fill: SolarSystemModelsColors.zodiacDividerColorProperty, + }); + this.addChild(divider); + } + } + } +} diff --git a/src/configurations/view/ConfigurationsDisplayPanel.ts b/src/configurations/view/ConfigurationsDisplayPanel.ts index 2cb8b6a..c4a6165 100644 --- a/src/configurations/view/ConfigurationsDisplayPanel.ts +++ b/src/configurations/view/ConfigurationsDisplayPanel.ts @@ -1,4 +1,5 @@ import { Text, VBox } from "scenerystack/scenery"; +import { PhetFont } from "scenerystack/scenery-phet"; import { Checkbox } from "scenerystack/sun"; import { SolarSystemModelsPanel } from "../../common/SolarSystemModelsPanel.js"; import { StringManager } from "../../i18n/StringManager.js"; @@ -7,7 +8,7 @@ import { PANEL_WIDTH } from "../../SolarSystemModelsConstants.js"; import type { ConfigurationsModel } from "../model/ConfigurationsModel.js"; const LABEL_OPTS = { - font: "14px sans-serif", + font: new PhetFont(14), fill: SolarSystemModelsColors.textColorProperty, maxWidth: PANEL_WIDTH - 60, } as const; diff --git a/src/configurations/view/ConfigurationsElongationIndicator.ts b/src/configurations/view/ConfigurationsElongationIndicator.ts index 1c96268..b128d6a 100644 --- a/src/configurations/view/ConfigurationsElongationIndicator.ts +++ b/src/configurations/view/ConfigurationsElongationIndicator.ts @@ -5,10 +5,9 @@ import type { ModelViewTransform2 } from "scenerystack/phetcommon"; import { Node, Path, Text } from "scenerystack/scenery"; import { ArrowNode, PhetFont } from "scenerystack/scenery-phet"; import SolarSystemModelsColors from "../../SolarSystemModelsColors.js"; +import { CONFIGURATIONS_ELONGATION_ARC_RADIUS } from "../../SolarSystemModelsConstants.js"; import type { ConfigurationsModel } from "../model/ConfigurationsModel.js"; -const ARC_RADIUS_VIEW = 35; // px — arc radius in view space - export class ConfigurationsElongationIndicator extends Node { public constructor(model: ConfigurationsModel, mvt: ModelViewTransform2) { super({ visibleProperty: model.showElongationAngleProperty }); @@ -74,13 +73,13 @@ export class ConfigurationsElongationIndicator extends Node { const endAngle = planetDir; // Determine sweep direction: elongDeg < 0 (East) means target is east of Sun const anticlockwise = elongDeg > 0; // W = clockwise sweep, E = anticlockwise - arcShape.arc(vp1.x, vp1.y, ARC_RADIUS_VIEW, startAngle, endAngle, anticlockwise); + arcShape.arc(vp1.x, vp1.y, CONFIGURATIONS_ELONGATION_ARC_RADIUS, startAngle, endAngle, anticlockwise); } arcPath.shape = arcShape; // Label at midpoint angle const midAngle = (sunDir + planetDir) / 2; - const labelR = ARC_RADIUS_VIEW + 14; + const labelR = CONFIGURATIONS_ELONGATION_ARC_RADIUS + 14; elongLabel.string = `${Math.abs(elongDeg).toFixed(1)}° ${elongLabel_}`; elongLabel.centerX = vp1.x + labelR * Math.cos(midAngle); elongLabel.centerY = vp1.y + labelR * Math.sin(midAngle); diff --git a/src/configurations/view/ConfigurationsKeyboardHelpContent.ts b/src/configurations/view/ConfigurationsKeyboardHelpContent.ts index 7a8fe0e..d641074 100644 --- a/src/configurations/view/ConfigurationsKeyboardHelpContent.ts +++ b/src/configurations/view/ConfigurationsKeyboardHelpContent.ts @@ -2,15 +2,26 @@ * ConfigurationsKeyboardHelpContent.ts * * Content for the keyboard-help dialog (the "?" button in the navigation bar). - * The template's only interactions are buttons and Reset All, so a single - * basic-actions section covers the available keyboard controls. Add a slider or - * combo-box section here as the simulation grows. + * Covers the screen's keyboard-accessible interactions: basic actions (Tab, + * Reset All), the observer/target planet combo boxes, and the NumberControl + * sliders (orbit radii, animation rate, pause time). Dragging the planets + * (and Shift-dragging to set epoch angle) and scrubbing/clicking the timeline + * are mouse/touch only — they have no keyboard equivalent, so they aren't + * documented here. */ -import { BasicActionsKeyboardHelpSection, TwoColumnKeyboardHelpContent } from "scenerystack/scenery-phet"; +import { + BasicActionsKeyboardHelpSection, + ComboBoxKeyboardHelpSection, + SliderControlsKeyboardHelpSection, + TwoColumnKeyboardHelpContent, +} from "scenerystack/scenery-phet"; export class ConfigurationsKeyboardHelpContent extends TwoColumnKeyboardHelpContent { public constructor() { - super([new BasicActionsKeyboardHelpSection()], []); + super( + [new BasicActionsKeyboardHelpSection(), new ComboBoxKeyboardHelpSection()], + [new SliderControlsKeyboardHelpSection()], + ); } } diff --git a/src/configurations/view/ConfigurationsScreenSummaryContent.ts b/src/configurations/view/ConfigurationsScreenSummaryContent.ts index 7582ead..0b6d250 100644 --- a/src/configurations/view/ConfigurationsScreenSummaryContent.ts +++ b/src/configurations/view/ConfigurationsScreenSummaryContent.ts @@ -13,26 +13,60 @@ * - currentDetailsContent — a LIVE paragraph describing current state * - interactionHintContent — a short hint on how to get started * - * ── Making "current details" live ───────────────────────────────────────────── - * The template has no model state, so currentDetails is a static string. In a - * real sim, build a DerivedProperty over the relevant model Properties and pass - * it as `currentDetailsContent` so the paragraph updates as the sim runs. - * See LunarLander/src/.../LunarLanderScreenSummaryContent.ts for the pattern. + * currentDetailsContent is a DerivedProperty over the observer/target planet + * presets, the current time, and (if locked) the current configuration name, + * so the paragraph updates as the sim runs. */ +import { DerivedProperty } from "scenerystack/axon"; import { ScreenSummaryContent } from "scenerystack/sim"; import { StringManager } from "../../i18n/StringManager.js"; import type { ConfigurationsModel } from "../model/ConfigurationsModel.js"; +import { PRESET_KEYS } from "../model/ConfigurationsPlanet.js"; export class ConfigurationsScreenSummaryContent extends ScreenSummaryContent { - // `model` is unused in the template but kept in the signature so real sims can - // derive a live currentDetailsContent from it without changing call sites. - public constructor(_model: ConfigurationsModel) { + public constructor(model: ConfigurationsModel) { const a11y = StringManager.getInstance().getConfigurationsA11yStrings(); + const strings = StringManager.getInstance().getConfigurationsStrings(); + + // Ordered to match PRESET_KEYS: mercury, venus, earth, mars, jupiter, saturn. + const planetLabelProperties = [ + strings.mercuryStringProperty, + strings.venusStringProperty, + strings.earthStringProperty, + strings.marsStringProperty, + strings.jupiterStringProperty, + strings.saturnStringProperty, + ] as const; + + const currentDetailsProperty = new DerivedProperty( + [ + model.preset1IndexProperty, + model.preset2IndexProperty, + model.timeProperty, + model.currentConfigurationProperty, + a11y.currentDetailsTemplateStringProperty, + a11y.currentConfigurationTemplateStringProperty, + ...planetLabelProperties, + ] as const, + (preset1Index, preset2Index, time, currentConfiguration, template, configTemplate, ...planetLabels) => { + const earthIndex = PRESET_KEYS.indexOf("earth"); + const observerLabel = planetLabels[preset1Index] ?? planetLabels[earthIndex]; + const targetLabel = planetLabels[preset2Index] ?? planetLabels[earthIndex]; + const configPart = currentConfiguration === "" ? "" : configTemplate.replace("{0}", currentConfiguration); + + return template + .replace("{0}", observerLabel ?? "") + .replace("{1}", targetLabel ?? "") + .replace("{2}", time.toFixed(2)) + .replace("{3}", configPart) + .trim(); + }, + ); super({ playAreaContent: a11y.screenSummary.playAreaStringProperty, controlAreaContent: a11y.screenSummary.controlAreaStringProperty, - currentDetailsContent: a11y.currentDetailsStringProperty, + currentDetailsContent: currentDetailsProperty, interactionHintContent: a11y.screenSummary.interactionHintStringProperty, }); } diff --git a/src/configurations/view/ConfigurationsScreenView.ts b/src/configurations/view/ConfigurationsScreenView.ts index 65814bd..1c00c74 100644 --- a/src/configurations/view/ConfigurationsScreenView.ts +++ b/src/configurations/view/ConfigurationsScreenView.ts @@ -121,6 +121,7 @@ export class ConfigurationsScreenView extends ScreenView { tagName: "div", focusable: true, accessibleName: a11y.controls.observerDragStringProperty, + accessibleHelpText: a11y.controls.observerShiftDragStringProperty, }); this.addChild(observerNode); @@ -132,6 +133,7 @@ export class ConfigurationsScreenView extends ScreenView { tagName: "div", focusable: true, accessibleName: a11y.controls.targetDragStringProperty, + accessibleHelpText: a11y.controls.targetShiftDragStringProperty, }); this.addChild(targetNode); @@ -190,9 +192,8 @@ export class ConfigurationsScreenView extends ScreenView { updateSunPos(); }; - Multilink.multilink( - [model.semimajorAxis1Property, model.semimajorAxis2Property, s.auStringProperty] as const, - () => updateOrbits(), + Multilink.multilink([model.semimajorAxis1Property, model.semimajorAxis2Property, s.auStringProperty] as const, () => + updateOrbits(), ); // ── Zodiac strip at bottom ────────────────────────────────────────────── diff --git a/src/configurations/view/ConfigurationsTimeReadout.ts b/src/configurations/view/ConfigurationsTimeReadout.ts index 21d7447..7a165e9 100644 --- a/src/configurations/view/ConfigurationsTimeReadout.ts +++ b/src/configurations/view/ConfigurationsTimeReadout.ts @@ -43,7 +43,9 @@ export class ConfigurationsTimeReadout extends SolarSystemModelsPanel { s.secondsStringProperty, ] as const, (remaining, pausedFor, second, seconds) => { - if (remaining <= 0) return ""; + if (remaining <= 0) { + return ""; + } const secs = Math.ceil(remaining); const unit = secs === 1 ? second : seconds; return pausedFor.replace("{0}", String(secs)).replace("{1}", unit); diff --git a/src/configurations/view/ConfigurationsTimeline.ts b/src/configurations/view/ConfigurationsTimeline.ts index 59d262d..2c6a050 100644 --- a/src/configurations/view/ConfigurationsTimeline.ts +++ b/src/configurations/view/ConfigurationsTimeline.ts @@ -1,24 +1,45 @@ import { Multilink } from "scenerystack/axon"; import { Shape } from "scenerystack/kite"; -import { DragListener, Node, Path, Rectangle, Text } from "scenerystack/scenery"; +import { DragListener, FireListener, Node, Path, Rectangle, Text } from "scenerystack/scenery"; import { PhetFont } from "scenerystack/scenery-phet"; import { Tandem } from "scenerystack/tandem"; import SolarSystemModelsColors from "../../SolarSystemModelsColors.js"; -import { CONFIGURATIONS_TIMELINE_HEIGHT, CONFIGURATIONS_TIMELINE_WIDTH } from "../../SolarSystemModelsConstants.js"; +import { + CONFIGURATIONS_TIMELINE_CYCLE_HEIGHT, + CONFIGURATIONS_TIMELINE_HEIGHT, + CONFIGURATIONS_TIMELINE_WIDTH, +} from "../../SolarSystemModelsConstants.js"; import type { ConfigurationsModel } from "../model/ConfigurationsModel.js"; const W = CONFIGURATIONS_TIMELINE_WIDTH; const H = CONFIGURATIONS_TIMELINE_HEIGHT; -const CYCLE_HEIGHT_PX = 120; - -function plotEventMark(shape: Shape, labelPool: Text[], labelIdx: number, y: number, name: string): number { +const CYCLE_HEIGHT_PX = CONFIGURATIONS_TIMELINE_CYCLE_HEIGHT; + +// Which (cycle, event) a pooled label currently represents — read by that +// label's FireListener at click-time, so clicking always slews to whatever +// event the label is currently showing. +type EventBinding = { cycle: number; event: number }; + +function plotEventMark( + shape: Shape, + labelPool: Text[], + bindings: EventBinding[], + labelIdx: number, + y: number, + name: string, + cycle: number, + event: number, +): number { shape.moveTo(0, y).lineTo(W * 0.4, y); const lbl = labelPool[labelIdx]; - if (lbl !== undefined) { + const binding = bindings[labelIdx]; + if (lbl !== undefined && binding !== undefined) { lbl.string = name; lbl.left = W * 0.42; lbl.centerY = y; lbl.visible = true; + binding.cycle = cycle; + binding.event = event; return labelIdx + 1; } return labelIdx; @@ -73,13 +94,24 @@ export class ConfigurationsTimeline extends Node { this.addChild(cursorLine); const labelPool: Text[] = []; + const labelBindings: EventBinding[] = []; for (let i = 0; i < 20; i++) { + const binding: EventBinding = { cycle: 0, event: 0 }; + labelBindings.push(binding); + const t = new Text("", { font: new PhetFont(9), fill: SolarSystemModelsColors.timelineLabelColorProperty, maxWidth: W - 8, + cursor: "pointer", }); t.visible = false; + t.addInputListener( + new FireListener({ + tandem: Tandem.OPT_OUT, + fire: () => model.slewToEvent(binding.cycle, binding.event), + }), + ); labelPool.push(t); this.addChild(t); } @@ -108,7 +140,7 @@ export class ConfigurationsTimeline extends Node { if (y < -20 || y > H + 20) { continue; } - labelIdx = plotEventMark(shape, labelPool, labelIdx, y, eventNames[e] ?? ""); + labelIdx = plotEventMark(shape, labelPool, labelBindings, labelIdx, y, eventNames[e] ?? "", cycle, e); checkLocked(selectedEventBg, model, e, t, time, synodic, y); } } diff --git a/src/configurations/view/ConfigurationsZodiacStrip.ts b/src/configurations/view/ConfigurationsZodiacStrip.ts index 6f28669..7db9e58 100644 --- a/src/configurations/view/ConfigurationsZodiacStrip.ts +++ b/src/configurations/view/ConfigurationsZodiacStrip.ts @@ -1,18 +1,14 @@ import { Multilink } from "scenerystack/axon"; -import { Circle, Node, Rectangle, Text } from "scenerystack/scenery"; +import { Circle, Node, Text } from "scenerystack/scenery"; import { PhetFont } from "scenerystack/scenery-phet"; +import { wrapToWidth, ZodiacStripBackground } from "../../common/ZodiacStripBackground.js"; import { StringManager } from "../../i18n/StringManager.js"; import SolarSystemModelsColors from "../../SolarSystemModelsColors.js"; +import { ZODIAC_STRIP_HEIGHT, ZODIAC_STRIP_WIDTH } from "../../SolarSystemModelsConstants.js"; import type { ConfigurationsModel } from "../model/ConfigurationsModel.js"; -const STRIP_WIDTH = 600; -const STRIP_HEIGHT = 60; const TWO_PI = 2 * Math.PI; -function mod(x: number, m: number): number { - return ((x % m) + m) % m; -} - export class ConfigurationsZodiacStrip extends Node { public constructor(model: ConfigurationsModel) { super(); @@ -33,43 +29,17 @@ export class ConfigurationsZodiacStrip extends Node { z.piscesStringProperty, ]; - // Band background - const band = new Rectangle(0, 0, STRIP_WIDTH, STRIP_HEIGHT, { - fill: SolarSystemModelsColors.zodiacBandColorProperty, - stroke: SolarSystemModelsColors.zodiacBorderColorProperty, - lineWidth: 1, - }); - this.addChild(band); - - // 12 sign labels - const segW = STRIP_WIDTH / 12; - for (let i = 0; i < 12; i++) { - const label = new Text(ZODIAC_SIGN_PROPS[i]!, { - font: new PhetFont(9), - fill: SolarSystemModelsColors.zodiacLabelColorProperty, - maxWidth: segW - 4, - }); - label.centerX = (i + 0.5) * segW; - label.centerY = STRIP_HEIGHT * 0.25; - this.addChild(label); - - // Divider - if (i > 0) { - const divider = new Rectangle(i * segW, 0, 1, STRIP_HEIGHT, { - fill: SolarSystemModelsColors.zodiacDividerColorProperty, - }); - this.addChild(divider); - } - } + // ── Shared band + sign labels + dividers ────────────────────────────── + this.addChild(new ZodiacStripBackground(ZODIAC_STRIP_WIDTH, ZODIAC_STRIP_HEIGHT, ZODIAC_SIGN_PROPS)); // Sun marker (yellow circle) const sunMarker = new Circle(5, { fill: SolarSystemModelsColors.sunColorProperty }); - sunMarker.centerY = STRIP_HEIGHT * 0.7; + sunMarker.centerY = ZODIAC_STRIP_HEIGHT * 0.7; this.addChild(sunMarker); // Planet/target marker (grey circle) const planetMarker = new Circle(5, { fill: SolarSystemModelsColors.targetPlanetColorProperty }); - planetMarker.centerY = STRIP_HEIGHT * 0.7; + planetMarker.centerY = ZODIAC_STRIP_HEIGHT * 0.7; this.addChild(planetMarker); // Elongation label @@ -78,7 +48,7 @@ export class ConfigurationsZodiacStrip extends Node { fill: SolarSystemModelsColors.elongationColorProperty, maxWidth: 120, }); - elongText.centerY = STRIP_HEIGHT * 0.7; + elongText.centerY = ZODIAC_STRIP_HEIGHT * 0.7; elongText.left = 4; this.addChild(elongText); @@ -86,12 +56,12 @@ export class ConfigurationsZodiacStrip extends Node { [model.pos1Property, model.pos2Property, model.elongationDegProperty, model.elongationLabelProperty] as const, (p1, p2, elongDeg, elongLabel) => { // Sun longitude = direction from observer (p1) to Sun (origin) - const sunLong = ((Math.atan2(-p1.y, -p1.x) % TWO_PI) + TWO_PI) % TWO_PI; + const sunLong = wrapToWidth(Math.atan2(-p1.y, -p1.x), TWO_PI); // Planet longitude = direction from observer (p1) to target (p2) - const planetLong = ((Math.atan2(p2.y - p1.y, p2.x - p1.x) % TWO_PI) + TWO_PI) % TWO_PI; + const planetLong = wrapToWidth(Math.atan2(p2.y - p1.y, p2.x - p1.x), TWO_PI); - sunMarker.centerX = mod((sunLong * STRIP_WIDTH) / TWO_PI, STRIP_WIDTH); - planetMarker.centerX = mod((planetLong * STRIP_WIDTH) / TWO_PI, STRIP_WIDTH); + sunMarker.centerX = wrapToWidth((sunLong * ZODIAC_STRIP_WIDTH) / TWO_PI, ZODIAC_STRIP_WIDTH); + planetMarker.centerX = wrapToWidth((planetLong * ZODIAC_STRIP_WIDTH) / TWO_PI, ZODIAC_STRIP_WIDTH); elongText.string = `${Math.abs(elongDeg).toFixed(1)}° ${elongLabel}`; }, diff --git a/src/i18n/strings_en.json b/src/i18n/strings_en.json index 3f0f1bd..892ae2f 100644 --- a/src/i18n/strings_en.json +++ b/src/i18n/strings_en.json @@ -74,9 +74,10 @@ "controlArea": "The control area has controls for the planet and the sizes and rates of its deferent and epicycle, plus a Reset All button that returns the screen to its starting state.", "interactionHint": "Adjust the deferent and epicycle to see how the planet appears to move as seen from Earth." }, - "currentDetails": "The screen is in its initial state.", + "currentDetailsTemplate": "Planet preset: {0}. The animation is {1}, running at {2} times speed.", + "playing": "playing", + "paused": "paused", "controls": { - "exampleControl": "Example control", "sunDrag": "Sun — drag to set angle", "planetPreset": "Planet preset", "epicycleSize": "Epicycle size", @@ -103,9 +104,9 @@ "controlArea": "The control area has controls for choosing a planet and its orbit and for animating the orbits, plus a Reset All button that returns the screen to its starting state.", "interactionHint": "Choose a planet and animate the orbits to see the planetary configurations form." }, - "currentDetails": "The screen is in its initial state.", + "currentDetailsTemplate": "Observer's planet: {0}. Target planet: {1}. Current time: {2} years. {3}", + "currentConfigurationTemplate": "Currently at {0}.", "controls": { - "exampleControl": "Example control", "observerPlanet": "Observer's planet selector", "targetPlanet": "Target planet selector", "observerAxis": "Observer orbit radius", @@ -120,8 +121,7 @@ "observerDrag": "Observer's planet — drag to set time", "targetDrag": "Target planet — drag to set time", "observerShiftDrag": "Observer's planet — Shift-drag to set epoch angle", - "targetShiftDrag": "Target planet — Shift-drag to set epoch angle", - "timelineScroll": "Timeline — drag to scrub time" + "targetShiftDrag": "Target planet — Shift-drag to set epoch angle" } } }, diff --git a/src/i18n/strings_es.json b/src/i18n/strings_es.json index e9e9d7d..5a039a4 100644 --- a/src/i18n/strings_es.json +++ b/src/i18n/strings_es.json @@ -74,9 +74,10 @@ "controlArea": "El área de control tiene controles para el planeta y para los tamaños y velocidades de su deferente y su epiciclo, además de un botón Restablecer todo que devuelve la pantalla a su estado inicial.", "interactionHint": "Ajusta el deferente y el epiciclo para ver cómo parece moverse el planeta visto desde la Tierra." }, - "currentDetails": "La pantalla está en su estado inicial.", + "currentDetailsTemplate": "Preset de planeta: {0}. La animación está {1}, a una velocidad de {2} veces.", + "playing": "en marcha", + "paused": "en pausa", "controls": { - "exampleControl": "Control de ejemplo", "sunDrag": "Sol — arrastrar para establecer el ángulo", "planetPreset": "Preset de planeta", "epicycleSize": "Tamaño del epiciclo", @@ -103,9 +104,9 @@ "controlArea": "El área de control tiene controles para elegir un planeta y su órbita y para animar las órbitas, además de un botón Restablecer todo que devuelve la pantalla a su estado inicial.", "interactionHint": "Elige un planeta y anima las órbitas para ver cómo se forman las configuraciones planetarias." }, - "currentDetails": "La pantalla está en su estado inicial.", + "currentDetailsTemplate": "Planeta del observador: {0}. Planeta objetivo: {1}. Tiempo actual: {2} años. {3}", + "currentConfigurationTemplate": "Actualmente en {0}.", "controls": { - "exampleControl": "Control de ejemplo", "observerPlanet": "Observer's planet selector", "targetPlanet": "Target planet selector", "observerAxis": "Observer orbit radius", @@ -120,8 +121,7 @@ "observerDrag": "Observer's planet — drag to set time", "targetDrag": "Target planet — drag to set time", "observerShiftDrag": "Observer's planet — Shift-drag to set epoch angle", - "targetShiftDrag": "Target planet — Shift-drag to set epoch angle", - "timelineScroll": "Timeline — drag to scrub time" + "targetShiftDrag": "Target planet — Shift-drag to set epoch angle" } } }, diff --git a/src/i18n/strings_fr.json b/src/i18n/strings_fr.json index c841af7..be196e3 100644 --- a/src/i18n/strings_fr.json +++ b/src/i18n/strings_fr.json @@ -74,9 +74,10 @@ "controlArea": "La zone de contrôle comporte des commandes pour la planète et pour les tailles et vitesses de son déférent et de son épicycle, ainsi qu'un bouton Tout réinitialiser qui ramène l'écran à son état initial.", "interactionHint": "Ajustez le déférent et l'épicycle pour voir comment la planète semble se déplacer vue depuis la Terre." }, - "currentDetails": "L'écran est dans son état initial.", + "currentDetailsTemplate": "Préréglage de planète : {0}. L'animation est {1}, à une vitesse de {2} fois.", + "playing": "en cours", + "paused": "en pause", "controls": { - "exampleControl": "Contrôle d'exemple", "sunDrag": "Soleil — faire glisser pour régler l'angle", "planetPreset": "Préréglage de planète", "epicycleSize": "Taille de l'épicycle", @@ -103,9 +104,9 @@ "controlArea": "La zone de contrôle comporte des commandes pour choisir une planète et son orbite et pour animer les orbites, ainsi qu'un bouton Tout réinitialiser qui ramène l'écran à son état initial.", "interactionHint": "Choisissez une planète et animez les orbites pour voir se former les configurations planétaires." }, - "currentDetails": "L'écran est dans son état initial.", + "currentDetailsTemplate": "Planète de l'observateur : {0}. Planète cible : {1}. Temps actuel : {2} ans. {3}", + "currentConfigurationTemplate": "Actuellement en {0}.", "controls": { - "exampleControl": "Contrôle d'exemple", "observerPlanet": "Observer's planet selector", "targetPlanet": "Target planet selector", "observerAxis": "Observer orbit radius", @@ -120,8 +121,7 @@ "observerDrag": "Observer's planet — drag to set time", "targetDrag": "Target planet — drag to set time", "observerShiftDrag": "Observer's planet — Shift-drag to set epoch angle", - "targetShiftDrag": "Target planet — Shift-drag to set epoch angle", - "timelineScroll": "Timeline — drag to scrub time" + "targetShiftDrag": "Target planet — Shift-drag to set epoch angle" } } }, diff --git a/src/ptolemaic/model/PtolemaicModel.ts b/src/ptolemaic/model/PtolemaicModel.ts index 3819532..76698bf 100644 --- a/src/ptolemaic/model/PtolemaicModel.ts +++ b/src/ptolemaic/model/PtolemaicModel.ts @@ -15,7 +15,7 @@ import { PTOLEMAIC_SUN_ORBIT_RADIUS, } from "../../SolarSystemModelsConstants.js"; import type { PlanetPresetKey } from "./PtolemaicPlanet.js"; -import { PLANET_PRESETS, PlanetType } from "./PtolemaicPlanet.js"; +import { PLANET_PRESETS, PlanetType, PRESET_KEYS } from "./PtolemaicPlanet.js"; type MemorySnapshot = { epicycleSize: number; @@ -64,9 +64,6 @@ export class PtolemaicModel implements TModel { private memory: MemorySnapshot | null = null; - // Ordered preset keys so control panel can reference by index - public static readonly PRESET_KEYS: readonly PlanetPresetKey[] = ["venus", "mars", "jupiter", "saturn"]; - public constructor() { this.timer = new TimeModel(false); @@ -86,7 +83,7 @@ export class PtolemaicModel implements TModel { // Index into PRESET_KEYS (1 = mars) this.presetKeyProperty = new NumberProperty(1, { - range: new Range(0, PtolemaicModel.PRESET_KEYS.length - 1), + range: new Range(0, PRESET_KEYS.length - 1), numberType: "Integer", }); @@ -148,7 +145,7 @@ export class PtolemaicModel implements TModel { // Apply Mars preset to keep presetKeyProperty in sync this.presetKeyProperty.lazyLink((idx) => { - const key = PtolemaicModel.PRESET_KEYS[idx]; + const key = PRESET_KEYS[idx]; if (key !== undefined) { this.applyPresetData(PLANET_PRESETS[key]); } @@ -185,7 +182,7 @@ export class PtolemaicModel implements TModel { // ── Public API ───────────────────────────────────────────────────────────── public applyPreset(key: PlanetPresetKey): void { - const idx = PtolemaicModel.PRESET_KEYS.indexOf(key); + const idx = PRESET_KEYS.indexOf(key); this.presetKeyProperty.value = idx; this.applyPresetData(PLANET_PRESETS[key]); } diff --git a/src/ptolemaic/model/PtolemaicPlanet.ts b/src/ptolemaic/model/PtolemaicPlanet.ts index 51aff25..ffbcdd4 100644 --- a/src/ptolemaic/model/PtolemaicPlanet.ts +++ b/src/ptolemaic/model/PtolemaicPlanet.ts @@ -49,4 +49,7 @@ export const PLANET_PRESETS = { export type PlanetPresetKey = keyof typeof PLANET_PRESETS; +// Ordered preset keys so the control panel and model can reference by index. +export const PRESET_KEYS: readonly PlanetPresetKey[] = ["venus", "mars", "jupiter", "saturn"]; + SolarSystemModelsNamespace.register("PlanetType", PlanetType); diff --git a/src/ptolemaic/view/PtolemaicControlPanel.ts b/src/ptolemaic/view/PtolemaicControlPanel.ts index f0a2624..768f5df 100644 --- a/src/ptolemaic/view/PtolemaicControlPanel.ts +++ b/src/ptolemaic/view/PtolemaicControlPanel.ts @@ -14,8 +14,7 @@ import { PANEL_WIDTH, } from "../../SolarSystemModelsConstants.js"; import type { PtolemaicModel } from "../model/PtolemaicModel.js"; -import type { PlanetPresetKey } from "../model/PtolemaicPlanet.js"; -import { PlanetType } from "../model/PtolemaicPlanet.js"; +import { PlanetType, PRESET_KEYS } from "../model/PtolemaicPlanet.js"; const TITLE_FONT = new PhetFont({ size: 13, weight: "bold" }); const LABEL_FONT = new PhetFont(13); @@ -27,7 +26,6 @@ export class PtolemaicControlPanel extends SolarSystemModelsPanel { const a11y = StringManager.getInstance().getPtolemaicA11yStrings(); // ── Planet preset ComboBox ───────────────────────────────────────────── - const presetKeys: PlanetPresetKey[] = ["venus", "mars", "jupiter", "saturn"]; const presetLabels = [ strings.venusStringProperty, strings.marsStringProperty, @@ -35,7 +33,7 @@ export class PtolemaicControlPanel extends SolarSystemModelsPanel { strings.saturnStringProperty, ]; - const comboItems = presetKeys.map((key, i) => { + const comboItems = PRESET_KEYS.map((key, i) => { const labelProp = presetLabels[i] ?? strings.marsStringProperty; return { value: i, diff --git a/src/ptolemaic/view/PtolemaicDisplayPanel.ts b/src/ptolemaic/view/PtolemaicDisplayPanel.ts index ef3dfa9..460dd15 100644 --- a/src/ptolemaic/view/PtolemaicDisplayPanel.ts +++ b/src/ptolemaic/view/PtolemaicDisplayPanel.ts @@ -1,4 +1,5 @@ import { Text, VBox } from "scenerystack/scenery"; +import { PhetFont } from "scenerystack/scenery-phet"; import { Checkbox } from "scenerystack/sun"; import { SolarSystemModelsPanel } from "../../common/SolarSystemModelsPanel.js"; import { StringManager } from "../../i18n/StringManager.js"; @@ -7,7 +8,7 @@ import { PANEL_WIDTH } from "../../SolarSystemModelsConstants.js"; import type { PtolemaicModel } from "../model/PtolemaicModel.js"; const LABEL_OPTS = { - font: "14px sans-serif", + font: new PhetFont(14), fill: SolarSystemModelsColors.textColorProperty, maxWidth: PANEL_WIDTH - 60, } as const; diff --git a/src/ptolemaic/view/PtolemaicKeyboardHelpContent.ts b/src/ptolemaic/view/PtolemaicKeyboardHelpContent.ts index f2e8333..d845377 100644 --- a/src/ptolemaic/view/PtolemaicKeyboardHelpContent.ts +++ b/src/ptolemaic/view/PtolemaicKeyboardHelpContent.ts @@ -2,15 +2,25 @@ * PtolemaicKeyboardHelpContent.ts * * Content for the keyboard-help dialog (the "?" button in the navigation bar). - * The template's only interactions are buttons and Reset All, so a single - * basic-actions section covers the available keyboard controls. Add a slider or - * combo-box section here as the simulation grows. + * Covers the screen's keyboard-accessible interactions: basic actions (Tab, + * Reset All), the planet preset combo box, and the NumberControl sliders + * (epicycle size, eccentricity, motion rate, apogee angle, animation rate, + * path duration). Sun dragging is mouse/touch only — it has no keyboard + * equivalent, so it isn't documented here. */ -import { BasicActionsKeyboardHelpSection, TwoColumnKeyboardHelpContent } from "scenerystack/scenery-phet"; +import { + BasicActionsKeyboardHelpSection, + ComboBoxKeyboardHelpSection, + SliderControlsKeyboardHelpSection, + TwoColumnKeyboardHelpContent, +} from "scenerystack/scenery-phet"; export class PtolemaicKeyboardHelpContent extends TwoColumnKeyboardHelpContent { public constructor() { - super([new BasicActionsKeyboardHelpSection()], []); + super( + [new BasicActionsKeyboardHelpSection(), new ComboBoxKeyboardHelpSection()], + [new SliderControlsKeyboardHelpSection()], + ); } } diff --git a/src/ptolemaic/view/PtolemaicScreenSummaryContent.ts b/src/ptolemaic/view/PtolemaicScreenSummaryContent.ts index 5e94b65..881ce33 100644 --- a/src/ptolemaic/view/PtolemaicScreenSummaryContent.ts +++ b/src/ptolemaic/view/PtolemaicScreenSummaryContent.ts @@ -13,26 +13,52 @@ * - currentDetailsContent — a LIVE paragraph describing current state * - interactionHintContent — a short hint on how to get started * - * ── Making "current details" live ───────────────────────────────────────────── - * The template has no model state, so currentDetails is a static string. In a - * real sim, build a DerivedProperty over the relevant model Properties and pass - * it as `currentDetailsContent` so the paragraph updates as the sim runs. - * See LunarLander/src/.../LunarLanderScreenSummaryContent.ts for the pattern. + * currentDetailsContent is a DerivedProperty over the current planet preset + * and the play/pause + animation rate state, so the paragraph updates as the + * sim runs. */ +import { DerivedProperty } from "scenerystack/axon"; import { ScreenSummaryContent } from "scenerystack/sim"; import { StringManager } from "../../i18n/StringManager.js"; import type { PtolemaicModel } from "../model/PtolemaicModel.js"; +import { PRESET_KEYS } from "../model/PtolemaicPlanet.js"; export class PtolemaicScreenSummaryContent extends ScreenSummaryContent { - // `model` is unused in the template but kept in the signature so real sims can - // derive a live currentDetailsContent from it without changing call sites. - public constructor(_model: PtolemaicModel) { + public constructor(model: PtolemaicModel) { const a11y = StringManager.getInstance().getPtolemaicA11yStrings(); + const strings = StringManager.getInstance().getPtolemaicStrings(); + + // Ordered to match PRESET_KEYS: venus, mars, jupiter, saturn. + const presetLabelProperties = [ + strings.venusStringProperty, + strings.marsStringProperty, + strings.jupiterStringProperty, + strings.saturnStringProperty, + ] as const; + + const currentDetailsProperty = new DerivedProperty( + [ + model.presetKeyProperty, + model.timer.isPlayingProperty, + model.timer.animationRateProperty, + a11y.currentDetailsTemplateStringProperty, + a11y.playingStringProperty, + a11y.pausedStringProperty, + ...presetLabelProperties, + ] as const, + (presetIndex, isPlaying, animationRate, template, playing, paused, ...presetLabels) => { + const presetLabel = presetLabels[presetIndex] ?? presetLabels[PRESET_KEYS.indexOf("mars")]; + return template + .replace("{0}", presetLabel ?? "") + .replace("{1}", isPlaying ? playing : paused) + .replace("{2}", animationRate.toFixed(1)); + }, + ); super({ playAreaContent: a11y.screenSummary.playAreaStringProperty, controlAreaContent: a11y.screenSummary.controlAreaStringProperty, - currentDetailsContent: a11y.currentDetailsStringProperty, + currentDetailsContent: currentDetailsProperty, interactionHintContent: a11y.screenSummary.interactionHintStringProperty, }); } diff --git a/src/ptolemaic/view/PtolemaicScreenView.ts b/src/ptolemaic/view/PtolemaicScreenView.ts index b2067cd..15278b3 100644 --- a/src/ptolemaic/view/PtolemaicScreenView.ts +++ b/src/ptolemaic/view/PtolemaicScreenView.ts @@ -18,7 +18,10 @@ import { PTOLEMAIC_DEFERENT_RADIUS, PTOLEMAIC_SUN_ORBIT_RADIUS, SCREEN_VIEW_MARGIN, + ZODIAC_LABEL_MAX_WIDTH, ZODIAC_LABEL_RADIUS, + ZODIAC_TICK_INNER_RADIUS, + ZODIAC_TICK_OUTER_RADIUS, } from "../../SolarSystemModelsConstants.js"; import type { PtolemaicModel } from "../model/PtolemaicModel.js"; import { PtolemaicControlPanel } from "./PtolemaicControlPanel.js"; @@ -29,7 +32,6 @@ import { PtolemaicTimeControls } from "./PtolemaicTimeControls.js"; import { PtolemaicTimeReadout } from "./PtolemaicTimeReadout.js"; import { PtolemaicZodiacStrip } from "./PtolemaicZodiacStrip.js"; - export class PtolemaicScreenView extends ScreenView { private readonly pathTrail: PtolemaicPathTrail; private readonly model: PtolemaicModel; @@ -86,18 +88,16 @@ export class PtolemaicScreenView extends ScreenView { this.addChild(constellationNode); // ── Zodiac sign border tick marks at sign boundaries ───────────────── - const tickInnerR = 250; - const tickOuterR = 270; for (let i = 0; i < 12; i++) { const angle = (i * Math.PI) / 6; // 0°, 30°, 60°, ... (sign boundaries) const tick = new Path(null, { stroke: SolarSystemModelsColors.zodiacTickColorProperty, lineWidth: 1, }); - const x1 = ORBIT_VIEW_CENTER_X + Math.cos(angle) * tickInnerR; - const y1 = ORBIT_VIEW_CENTER_Y - Math.sin(angle) * tickInnerR; - const x2 = ORBIT_VIEW_CENTER_X + Math.cos(angle) * tickOuterR; - const y2 = ORBIT_VIEW_CENTER_Y - Math.sin(angle) * tickOuterR; + const x1 = ORBIT_VIEW_CENTER_X + Math.cos(angle) * ZODIAC_TICK_INNER_RADIUS; + const y1 = ORBIT_VIEW_CENTER_Y - Math.sin(angle) * ZODIAC_TICK_INNER_RADIUS; + const x2 = ORBIT_VIEW_CENTER_X + Math.cos(angle) * ZODIAC_TICK_OUTER_RADIUS; + const y2 = ORBIT_VIEW_CENTER_Y - Math.sin(angle) * ZODIAC_TICK_OUTER_RADIUS; tick.shape = new Shape().moveTo(x1, y1).lineTo(x2, y2); this.addChild(tick); } @@ -111,7 +111,7 @@ export class PtolemaicScreenView extends ScreenView { const label = new Text(ZODIAC_SIGN_PROPS[i]!, { font: new PhetFont(10), fill: SolarSystemModelsColors.zodiacLabelColorProperty, - maxWidth: 55, + maxWidth: ZODIAC_LABEL_MAX_WIDTH, }); label.centerX = vx; label.centerY = vy; diff --git a/src/ptolemaic/view/PtolemaicZodiacStrip.ts b/src/ptolemaic/view/PtolemaicZodiacStrip.ts index 35ceebd..89bd750 100644 --- a/src/ptolemaic/view/PtolemaicZodiacStrip.ts +++ b/src/ptolemaic/view/PtolemaicZodiacStrip.ts @@ -1,7 +1,7 @@ import { Shape } from "scenerystack/kite"; -import { Circle, Node, Path, Rectangle, Text } from "scenerystack/scenery"; -import { PhetFont } from "scenerystack/scenery-phet"; +import { Circle, Node, Path, Rectangle } from "scenerystack/scenery"; import { ECLIPTIC_CONSTELLATIONS } from "../../common/ZodiacConstellationsData.js"; +import { ZodiacStripBackground } from "../../common/ZodiacStripBackground.js"; import { StringManager } from "../../i18n/StringManager.js"; import SolarSystemModelsColors from "../../SolarSystemModelsColors.js"; import { ZODIAC_STRIP_HEIGHT, ZODIAC_STRIP_WIDTH } from "../../SolarSystemModelsConstants.js"; @@ -85,13 +85,8 @@ export class PtolemaicZodiacStrip extends Node { z.ariesStringProperty, ]; - // Background band - const band = new Rectangle(0, 0, ZODIAC_STRIP_WIDTH, ZODIAC_STRIP_HEIGHT, { - fill: SolarSystemModelsColors.zodiacBandColorProperty, - stroke: SolarSystemModelsColors.orbitColorProperty, - lineWidth: 1, - }); - this.addChild(band); + // ── Shared band + sign labels + dividers ────────────────────────────── + this.addChild(new ZodiacStripBackground(ZODIAC_STRIP_WIDTH, ZODIAC_STRIP_HEIGHT, SIGN_NAME_PROPS)); // ── Constellation stick figures ────────────────────────────────────── const constelShape = buildConstellationShape(ZODIAC_STRIP_WIDTH, ZODIAC_STRIP_HEIGHT); @@ -106,25 +101,6 @@ export class PtolemaicZodiacStrip extends Node { }); this.addChild(constelPath); - // ── Dividers and sign labels ────────────────────────────────────────── - const segW = ZODIAC_STRIP_WIDTH / 12; - for (let i = 0; i < 12; i++) { - const x = i * segW; - const divider = new Rectangle(x, 0, 1, ZODIAC_STRIP_HEIGHT, { - fill: SolarSystemModelsColors.orbitColorProperty, - }); - this.addChild(divider); - - const label = new Text(SIGN_NAME_PROPS[i]!, { - font: new PhetFont(9), - fill: SolarSystemModelsColors.zodiacLabelColorProperty, - maxWidth: segW - 4, - }); - label.centerX = (i + 0.5) * segW; - label.centerY = ZODIAC_STRIP_HEIGHT * 0.25; - this.addChild(label); - } - // ── Sun marker (yellow circle) ────────────────────────────────────── const sunMarker = new Circle(7, { fill: SolarSystemModelsColors.sunColorProperty,