Skip to content

feat(lunar): add a solar/lunar/both mode to the detail view - #167

Open
daneden wants to merge 5 commits into
claude/solstice-solar-eclipse-n2k27cfrom
claude/solstice-lunar-mode-n2k27c
Open

feat(lunar): add a solar/lunar/both mode to the detail view#167
daneden wants to merge 5 commits into
claude/solstice-solar-eclipse-n2k27cfrom
claude/solstice-lunar-mode-n2k27c

Conversation

@daneden

@daneden daneden commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Stacked on #166 — based on claude/solstice-solar-eclipse-n2k27c so the diff shows only the lunar work. Retarget to main once #166 merges.

Adds a toolbar control that switches the detail view between the sun, the moon, and both.

Why this was cheaper than it looks

EclipseCalculator already held a complete Meeus lunar theory — chapter 47's truncated ELP-2000/82 series, topocentric parallax, nutation, sidereal time and ΔT — and every bit of it was private. None of it is specific to eclipses. The first commit lifts it into Ephemeris; the second builds on it.

The extraction is numerically inert, and checkably so: counting every numeric literal across both files gives 937 before and 937 after. The only differences are the lunation index going from spelled out twice to once, and two new constants. EclipseCalculatorTests is unchanged and is the real check.

One constant deliberately does not become the default. The moon radius in EclipseCalculator was the umbral k = 0.272281, chosen so totality durations match NASA's, and it is the wrong figure for how large the moon looks. Ephemeris exposes the mean radius as moonRadiusKm and keeps the eclipse value as umbralMoonRadiusKm.

The one piece of genuinely new astronomy

Moonrise and moonset. The sun's fall out of a single daily position; the moon moves ~13°/day, so altitude is sampled across the local day and the crossings bisected.

The trap worth knowing about: Meeus gives h₀ = 0.7275·π − 34′ for moonrise, but that is for geocentric positions — the 0.7275·π term exists to fold in parallax. Ephemeris returns topocentric positions, where parallax is already applied, so using Meeus's figure would move moonrise by the better part of two hours. What remains is refraction and the semidiameter, as for the sun. The test that the full moon rises as the sun sets exists to catch exactly that.

Both times are optional, and a missing one is ordinary rather than an error: about once a lunation a calendar day has no moonrise, or no moonset, at every latitude.

Phases

Eight equal 45° slices would label a moon that is 68% lit "first quarter" — a first quarter moon is half lit, and anyone who looked up would see the app was wrong. The four principal phases get a narrow window instead and the crescents and gibbous phases take everything between. Meeus's own worked example falls in that gap and now reads as waxing gibbous.

Glyphs are mirrored below the equator: the moon is lit from the same side for everyone, but southern observers see it the other way up.

Verification

Asserted against the book, which the extraction made possible for the first time — these were previously only checked indirectly, through whether an eclipse landed in the right place:

Reference Result
Meeus 47.a (lunar position) λ, β, Δ to six decimal places
Meeus 25.b (solar position) matches
Meeus 48.a (illuminated fraction) 0.6786

And against physics, where no published example exists:

  • The full moon rises as the sun sets — sun altitude at moonrise came out at −0.94°.
  • Daily retardation of moonrise is 11–23 min at London in late summer, not the "average" 50 — the shallow-ecliptic-angle effect behind the Harvest Moon.
  • Successive full moons a synodic month apart.
  • The phase search agrees with the independently written eclipse search about the 2026 new moon, to 11 minutes — the expected offset between conjunction in longitude and greatest local eclipse.

Judgement calls

  • The sky gradient stays solar in every mode. It's driven by sun altitude, and keeping it as the backdrop is what makes the moon's path readable — you can see whether the moon is up in darkness or wasted in daylight. It also gives "both" a clean meaning.
  • The moon is computed regardless of mode, so switching shows data immediately rather than after a round trip. It costs a few hundred microseconds off the main thread, once per day and place.
  • Moon altitude samples use the existing SkyRenderCache pattern rather than .task — the share card renders this chart through ImageRenderer, which never runs the view lifecycle, so anything task-driven would come out blank there.

Not verified

No Swift toolchain in the session, so none of this has been compiled or run — the validation above is via a line-for-line Python port of the same algorithms. Worth a close look on first build:

Follow-up

PR 2 will add the phase calendar replacing the annual chart in lunar mode. Lunar eclipses are deliberately deferred.


Generated by Claude Code

claude added 5 commits August 11, 2026 14:38
EclipseCalculator held a complete Meeus lunar and solar theory — chapter 47's
truncated ELP-2000/82 series, topocentric parallax, nutation, sidereal time and
ΔT — and every bit of it was private to that file. None of it is specific to
eclipses, and none of it could be used for anything else.

Move it to Ephemeris. EclipseCalculator keeps only the eclipse geometry:
finding lunations whose shadow reaches the Earth, and what an observer at one
place sees of it.

The move is numerically inert. Counting every numeric literal across both files
gives 937 before and 937 after; the only differences are that the lunation index
went from being spelled out twice to once, and two new constants for the mean
lunar radius and the synodic month. EclipseCalculatorTests is unchanged and is
the real check.

One constant deliberately does not become the default. The moon radius here was
the umbral k = 0.272281, chosen so totality durations match NASA's, and it is
the wrong figure for how large the moon looks. Ephemeris exposes the mean radius
as moonRadiusKm and keeps the eclipse value as umbralMoonRadiusKm, so eclipse
contacts still use it and nothing else picks it up by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PR3DD68Nh3jRJ6H7z8fHn1
Adds LunarCalculator on top of the extracted Ephemeris, and a toolbar control
that switches what the daily chart and rows are about.

Moonrise and moonset are the only genuinely new astronomy. The sun's rise and
set fall out of a single daily position; the moon moves about 13 degrees a day,
so its altitude is sampled across the local day and the horizon crossings are
bisected. Both are optional, and a missing one is ordinary rather than an
error: the moon rises roughly 50 minutes later each day, so about once a
lunation a calendar day has no moonrise, or no moonset, at every latitude.

The standard altitude deserves a note. Meeus gives h0 = 0.7275*pi - 34' for
moonrise, but that is for use with geocentric positions — the 0.7275*pi term
exists to fold in parallax. Ephemeris returns topocentric positions, where
parallax is already applied, so applying it again would move moonrise by the
better part of two hours. What remains is refraction and the semidiameter,
exactly as for the sun. The test that the full moon rises as the sun sets is
there to catch precisely that mistake.

Phases use a narrow window around each principal phase rather than eight equal
45-degree slices. Equal slices would label a moon that is 68% lit "first
quarter", and a first quarter moon is half lit — anyone who looked up would see
the app was wrong. Meeus's own worked example falls in that gap and now reads
as waxing gibbous, which is what it is.

Checked against the book where it can be: chapter 47 for position, 25 for the
sun and 48 for illumination are now asserted directly rather than only through
whether an eclipse landed in the right place. The rest is checked against
physics — the full moon rising at sunset, the daily retardation of moonrise
showing the shallow autumn angle, successive full moons a synodic month apart,
and the phase search agreeing with the independently written eclipse search
about when the 2026 new moon is.

The sky gradient stays solar in every mode. It is driven by the sun's altitude,
and keeping it as the backdrop is what makes the moon's path readable — you can
see whether the moon is up in darkness or wasted in daylight. That also gives
"both" a clean meaning: the sky is always the sun's, the mode picks which
bodies are drawn on it.

Phase glyphs are mirrored below the equator. The moon is lit from the same side
for everyone, but southern observers see it the other way up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PR3DD68Nh3jRJ6H7z8fHn1
Xcode Cloud could not open the project at all: "The project 'Solstice' is
damaged and cannot be opened due to a parse error." Both checks came back after
about 20 seconds against 5-12 minutes for a real build, which was the tell that
nothing had been compiled.

project.pbxproj is an OpenStep property list, where an unquoted token may only
contain [A-Za-z0-9_./]. A plus sign has to be quoted. The script that wired the
three new files in used one template for all of them, which is fine for
Ephemeris.swift and LunarCalculator.swift and unparseable for the one name with
a ++ in it. The repo's own NTSolar++.swift has always been quoted.

Checking brace and paren balance after editing the file, as I did, could never
have caught this — balance says nothing about token grammar. The check that
would have is:

    grep -nE 'path = [^";]*[^A-Za-z0-9_./;][^";]*;' Solstice.xcodeproj/project.pbxproj

No Swift changed. The project never opened, so the build has said nothing yet
about whether the lunar code compiles; guessing at Swift fixes now would only
muddy the next run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PR3DD68Nh3jRJ6H7z8fHn1
…traction

Three call sites in EclipseCalculator still referred to date(fromJulianDay:)
unqualified after it moved to Ephemeris, so the module failed to build with
"Cannot find 'date' in scope".

The check I ran after the extraction looked for unqualified uses of the moved
symbols, but against a hand-written list — and `date` was not on it. Deriving
the list from Ephemeris itself instead finds these immediately, and finds
nothing else:

    names = static funcs + static lets + structs declared in Ephemeris.swift
    flag any use of those names in EclipseCalculator/LunarCalculator that is
    not preceded by a dot and not defined locally

The remaining hits from that scan are argument labels — `after date: Date` and
`Ephemeris.deltaT(julianDay:)` — not references to the moved functions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PR3DD68Nh3jRJ6H7z8fHn1
…he jank

Feedback from the first build on device.

The moon's path had no marker, so nothing showed where the moon actually is.
It now gets the phase glyph — the same symbol the moon row uses, which carries
information a plain dot would not. Positioned directly rather than through
AlongSolarPath: that machinery exists to glide the sun's marker during a scrub,
and given the animation problem below the moon should not carry it.

The moon's line drops from markSize/2 to markSize/3 so it reads as clearly
secondary to the sun's path.

In lunar-only mode the sun's circle and its glow both go. Two separate things:
the marker is hidden rather than skipped, because sunBelowHorizon also draws
the below-horizon veil and applies the chart's mask, and those belong to the
chart rather than to the sun. The glow goes by leaving sunPoint unset, which is
what becomes the SkyGradient's sunAnchor — publishing it with no sun drawn left
a bright highlight tracking an invisible marker. The sky keeps its day/night
colouring either way, since that comes from date and horizonY.

The lunar rows move into their own section. Interleaved with the sun's, the two
bodies' times read as a single list.

On the sluggishness: findMoon assigned through withAnimation, and its task is
keyed on the day, so every day change during time travel animated the entire
form. The phase glyph's contentTransition fired on the same data arrival. Both
removed. The mode-switch animation stays — it runs only on a deliberate tap,
and a section appearing with no transition is jarring.

Worth noting why the eclipse section, which is keyed the same way, never caused
this: its result is identical for months at a time, so the assignment re-assigns
an equal value and SwiftUI does nothing. The moon changes every day.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PR3DD68Nh3jRJ6H7z8fHn1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants