krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

Packages/MoonriseKit/Sources/Answers/SkyAnswers.swift

6c4b28384d65e09486e0ad62f2ac9d10401cc80c
moonrise/Packages/MoonriseKit/Sources/Answers/SkyAnswers.swift history · blame · raw

165 lines · 7079 bytes

  1import Foundation
  2import Library
  3import SkyEngine
  4
  5/// The Moon's phase now and its next quarter.
  6public struct MoonAnswer: Sendable {
  7    public var phaseName: String
  8    /// Fraction of the disc lit, 0...1.
  9    public var illumination: Double
 10    public var waxing: Bool
 11    public var nextQuarter: SkyEngine.MoonQuarter
 12    public var nextQuarterDate: Date
 13    public var rows: [AnswerRow]
 14    public var spoken: String
 15
 16    public static func quarterName(_ q: SkyEngine.MoonQuarter) -> String {
 17        switch q {
 18        case .new: "New moon"
 19        case .firstQuarter: "First quarter"
 20        case .full: "Full moon"
 21        case .lastQuarter: "Last quarter"
 22        }
 23    }
 24
 25    public init(at date: Date, locale: Locale = .current, timeZone: TimeZone = .current) throws {
 26        let say = Phrasing(locale: locale, timeZone: timeZone)
 27        let angle = try SkyEngine.moonPhase(at: date)
 28        phaseName = DailySummary.phaseName(angle)
 29        illumination = try SkyEngine.illumination(.moon, at: date).phaseFraction
 30        waxing = angle < 180
 31        (nextQuarter, nextQuarterDate) = try SkyEngine.nextMoonQuarter(after: date)
 32        let lit = "\(Int((illumination * 100).rounded()))%"
 33        let quarter = Self.quarterName(nextQuarter)
 34        rows = [
 35            AnswerRow("Phase", phaseName),
 36            AnswerRow("Illuminated", lit),
 37            AnswerRow(quarter, say.short(nextQuarterDate, from: date)),
 38        ]
 39        spoken = "\(phaseName), \(lit) lit. \(quarter) \(say.when(nextQuarterDate, from: date))."
 40    }
 41}
 42
 43/// Naked-eye planets well placed tonight, with sunset and sunrise.
 44public struct TonightAnswer: Sendable {
 45    public struct Planet: Sendable, Hashable {
 46        public var name: String
 47        /// "up now, 32° SE", "rises 21:10" or "highest 22:40, 41°".
 48        public var detail: String
 49    }
 50
 51    public var planets: [Planet]
 52    public var sunset: Date?
 53    public var sunrise: Date?
 54    public var heading: String
 55    public var rows: [AnswerRow]
 56    public var spoken: String
 57
 58    public init(at date: Date, place: Place, locale: Locale = .current, timeZone: TimeZone = .current) throws {
 59        let say = Phrasing(locale: locale, timeZone: timeZone)
 60        let t = try Tonight(at: date, from: place.observer)
 61        sunset = t.sunset
 62        sunrise = t.sunrise
 63        heading = "Tonight · \(place.label)"
 64
 65        let placed = t.planets.filter { $0.best != nil }
 66        planets = placed.map { p in
 67            let detail: String
 68            if p.now.altitude > 0 {
 69                detail = "up now, \(Int(p.now.altitude.rounded()))° \(Compass.point(p.now.azimuth))"
 70            } else if let rise = p.rise, rise < t.end {
 71                detail = "rises \(say.time(rise))"
 72            } else {
 73                detail = "highest \(say.time(p.best!.date)), \(Int(p.best!.position.altitude.rounded()))°"
 74            }
 75            return Planet(name: p.body.rawValue.capitalized, detail: detail)
 76        }
 77
 78        var sun: [String] = []
 79        if let s = t.sunset { sun.append("sets \(say.time(s))") }
 80        if let r = t.sunrise { sun.append("rises \(say.time(r))") }
 81        rows = (planets.isEmpty ? [AnswerRow("Planets", "none well placed")] : planets.prefix(3).map { AnswerRow($0.name, $0.detail) })
 82            + [AnswerRow("Sun", sun.joined(separator: " · "))]
 83
 84        var lines: [String] = []
 85        if let first = placed.first {
 86            let name = first.body.rawValue.capitalized
 87            lines.append("Up tonight: \(say.list(planets.map(\.name))).")
 88            if first.now.altitude > 0 {
 89                lines.append("\(name) is up now, \(Int(first.now.altitude.rounded()))° in the \(Compass.name(first.now.azimuth)).")
 90            } else if let rise = first.rise, rise < t.end {
 91                lines.append("\(name) rises \(say.when(rise, from: date)).")
 92            } else if let best = first.best {
 93                lines.append("\(name) is highest \(say.when(best.date, from: date)), \(Int(best.position.altitude.rounded()))° up.")
 94            }
 95        } else {
 96            lines.append("No bright planets are well placed tonight.")
 97        }
 98        if let s = t.sunset {
 99            lines.append("Sunset \(say.time(s)).")
100        } else if let r = t.sunrise {
101            lines.append("Sunrise \(say.time(r)).")
102        }
103        spoken = lines.sentences(place.note)
104    }
105}
106
107/// The three satellites the app features, by NORAD catalogue number.
108public enum Satellite: Int, CaseIterable, Sendable {
109    case iss = 25544, tiangong = 48274, hubble = 20580
110
111    public var name: String {
112        switch self {
113        case .iss: "ISS"
114        case .tiangong: "Tiangong"
115        case .hubble: "Hubble"
116        }
117    }
118
119    var subject: String { self == .iss ? "The ISS" : name }
120}
121
122/// The next pass of one satellite, preferring one that can be seen.
123public struct PassAnswer: Sendable {
124    public var satellite: Satellite
125    public var pass: SatellitePass?
126    public var rows: [AnswerRow]
127    public var spoken: String
128
129    /// `passes` are this satellite's, in time order, as `SkyEngine.passes` returns them for the next three days.
130    public init(satellite: Satellite, passes: [SatellitePass], at date: Date, place: Place, locale: Locale = .current, timeZone: TimeZone = .current) {
131        let say = Phrasing(locale: locale, timeZone: timeZone)
132        self.satellite = satellite
133        let upcoming = passes.filter { $0.set > date }
134        let visible = upcoming.first(where: \.visible)
135        pass = visible ?? upcoming.first
136        guard let p = pass else {
137            rows = [AnswerRow("Next pass", "none above 10° in 3 days")]
138            spoken = "No \(satellite.name) passes above 10° in the next three days."
139            return
140        }
141        let alt = "\(Int(p.maxAltitude.rounded()))°"
142        rows = [
143            AnswerRow("Rises", "\(say.short(p.rise, from: date)) \(Compass.point(p.riseAzimuth))", alert: p.rise.timeIntervalSince(date) < 15 * 60),
144            AnswerRow("Highest", "\(say.time(p.peak)), \(alt)"),
145            AnswerRow("Sets", "\(say.time(p.set)) \(Compass.point(p.setAzimuth))"),
146            AnswerRow("Visible", p.visible ? "yes" : "no"),
147        ]
148        let rise = "rises \(say.when(p.rise, from: date)) in the \(Compass.name(p.riseAzimuth)), \(alt) high."
149        if visible != nil {
150            spoken = ["\(satellite.subject) \(rise) Visible to the eye."].sentences(place.note)
151        } else {
152            spoken = ["No visible \(satellite.name) passes in the next three days. The next one \(rise)"].sentences(place.note)
153        }
154    }
155}
156
157extension LibraryPage {
158    /// Where the Sky tab can turn to for this page: the Moon, the planets, the 88 constellations and the named stars.
159    public var skyTarget: Tour.Target? {
160        if let c = link.constellation { return Tour.Target(constellation: c) }
161        if let hr = link.hr { return Tour.Target(hr: hr) }
162        if let b = link.body, b == Body.moon.rawValue || Body.planets.contains(where: { $0.rawValue == b }) { return Tour.Target(body: b) }
163        return nil
164    }
165}