// // 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 ) } }