Packages/MoonriseKit/Sources/Answers/SkyAnswers.swift
165 lines · 7079 bytes
6 symbols in this file
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}