Files
Moon-Watch/Shared/MoonPhaseCalculator.swift
dave-enterprise 87f8c7173c Add MoonWatch app, widget extension, shared module, and .gitignore
Adds the real MoonWatch implementation on top of the initial Xcode
template: new SwiftUI views (MoonDiskView, StarFieldView), the
MoonWatchWidget extension, and a Shared module containing the moon
phase calculator, shadow shape, and shared asset catalog. Also adds a
Swift/Xcode/macOS .gitignore and untracks per-user xcuserdata files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 21:05:16 -07:00

103 lines
4.0 KiB
Swift

//
// MoonPhaseCalculator.swift
// Shared between MoonWatch and MoonWatchWidget.
//
import Foundation
public enum MoonPhaseCalculator: Sendable {
/// Mean synodic month length in days (Meeus).
public static let synodicMonth = 29.530588853
/// Julian date at Unix epoch (1970-01-01 00:00 UTC).
private static let julianDayUnixEpoch = 24_405_87.5
/// Known new moon reference: 2000-01-06 18:14 UTC, JD 2451550.09765.
private static let julianDayKnownNewMoon = 2_451_550.09765
public static func julianDay(for date: Date) -> Double {
date.timeIntervalSince1970 / 86_400.0 + julianDayUnixEpoch
}
/// Days since the reference new moon, wrapped to `[0, synodicMonth)`.
public static func moonAgeDays(for date: Date) -> Double {
let jd = julianDay(for: date)
var age = jd - julianDayKnownNewMoon
age = age.truncatingRemainder(dividingBy: synodicMonth)
if age < 0 { age += synodicMonth }
return age
}
/// Normalized phase in `[0, 1)`; 0 is new moon.
public static func normalizedPhase(for date: Date) -> Double {
moonAgeDays(for: date) / synodicMonth
}
/// Illuminated fraction of the apparent disk (simple geometric model).
public static func illuminatedFraction(for date: Date) -> Double {
let t = normalizedPhase(for: date)
let angle = 2 * Double.pi * t
return (1 - cos(angle)) / 2
}
public static func namedPhase(normalizedPhase t: Double) -> MoonPhaseName {
let x = t.truncatingRemainder(dividingBy: 1)
let u = x < 0 ? x + 1 : x
// Cardinal phases (New, First Quarter, Full, Last Quarter) are astronomical
// moments, not equal eighths of the cycle. Treat them as narrow windows of
// 1/16 of the cycle (~1.85 days) centered at 0, 0.25, 0.5, 0.75; the four
// intermediate phases fill the 3/16 gaps between them.
let step = u * 32
switch step {
case ..<1, 31...: return .newMoon
case 1..<7: return .waxingCrescent
case 7..<9: return .firstQuarter
case 9..<15: return .waxingGibbous
case 15..<17: return .fullMoon
case 17..<23: return .waningGibbous
case 23..<25: return .thirdQuarter
default: return .waningCrescent
}
}
/// Returns the next phase transition: which phase comes next and how many
/// days until the boundary is crossed.
public static func nextPhaseTransition(normalizedPhase t: Double) -> (phase: MoonPhaseName, daysAway: Double) {
let raw = t.truncatingRemainder(dividingBy: 1)
let u = raw < 0 ? raw + 1 : raw
let step = u * 32
// Each entry is (boundary in 32nds, the phase that begins at that boundary)
let transitions: [(Double, MoonPhaseName)] = [
(1, .waxingCrescent),
(7, .firstQuarter),
(9, .waxingGibbous),
(15, .fullMoon),
(17, .waningGibbous),
(23, .thirdQuarter),
(25, .waningCrescent),
(31, .newMoon),
]
for (boundary, phase) in transitions where step < boundary {
return (phase, (boundary / 32.0 - u) * synodicMonth)
}
// Late new-moon window (step 31): next is waxing crescent at 1/32 next cycle
return (.waxingCrescent, (1.0 + 1.0 / 32.0 - u) * synodicMonth)
}
public static func snapshot(for date: Date) -> MoonPhaseSnapshot {
let age = moonAgeDays(for: date)
let norm = age / synodicMonth
let (nextPhase, daysToNext) = nextPhaseTransition(normalizedPhase: norm)
return MoonPhaseSnapshot(
date: date,
normalizedPhase: norm,
moonAgeDays: age,
namedPhase: namedPhase(normalizedPhase: norm),
illuminatedFraction: illuminatedFraction(for: date),
nextPhaseName: nextPhase,
daysToNextPhase: daysToNext
)
}
}