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>
103 lines
4.0 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|