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

App/Sources/SunScreen.swift

6c4b28384d65e09486e0ad62f2ac9d10401cc80c
moonrise/App/Sources/SunScreen.swift history · blame · raw

372 lines · 18300 bytes

  1import Charts
  2import CoreLocation
  3import Feeds
  4import SkyEngine
  5import SkyRenderer
  6import SwiftUI
  7
  8struct SunScreen: View {
  9    @State private var wavelength = SolarImage.corona
 10    @State private var data: SunSnapshot?
 11    @State private var summary: String?
 12    @State private var failed = false
 13    @State private var imageID = Date.now
 14    @State private var picture: Picture?
 15    @State private var imageFailed = false
 16    @Environment(\.dynamicTypeSize) private var typeSize
 17
 18    private var kp: [KpBar] { data?.kp ?? [] }
 19    private var xray: [XRaySample] { data?.xray ?? [] }
 20    private var ovation: Ovation? { data?.ovation }
 21
 22    private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
 23    private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
 24    private let background = Color.black
 25
 26    var body: some View {
 27        ScrollView {
 28            VStack(alignment: .leading, spacing: 0) {
 29                Text("Sun").font(.title.weight(.medium))
 30                Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
 31                if let summary, let data {
 32                    Text(summary)
 33                    Text(SunFeeds.source(data)).font(.caption).dimmed(0.5).padding(.top, 4)
 34                } else if !failed {
 35                    Text("Loading").font(.footnote).dimmed(0.6)
 36                }
 37                if failed {
 38                    Text(data == nil ? "Couldn't load space weather. Pull to retry." : "Couldn't reach NOAA. Showing the last saved data. Pull to retry.")
 39                        .font(.footnote).foregroundStyle(accent).padding(.top, 8)
 40                }
 41                if let scales = data?.scales {
 42                    section("NOAA scales", note: "Radio blackouts (R), solar radiation storms (S) and geomagnetic storms (G), each from 0 (none) to 5 (extreme).") { scalesStrip(scales) }
 43                }
 44
 45                image.padding(.top, 24)
 46                Picker("Wavelength", selection: $wavelength) {
 47                    ForEach(SolarImage.allCases) { Text($0.name).tag($0) }
 48                }
 49                .pickerStyle(.segmented)
 50                .padding(.top, 12)
 51                Text(wavelength.caption).font(.footnote).dimmed(0.75).padding(.top, 8)
 52                Text(SolarImage.credit).font(.caption2).dimmed(0.5).padding(.top, 4)
 53
 54                section("Aurora tonight", note: "NOAA's forecast of where the aurora may be overhead in the next hour; brighter green is more likely. From a few hundred kilometres further from the pole it can still be seen low on the horizon.") { auroraMap }
 55                section("Solar flares, last 24 h", note: "X-rays from the Sun, measured by NOAA's GOES satellites. Spikes are flares: M and X class can disrupt radio and GPS, and the biggest can bring a storm a day or two later.") {
 56                    flares
 57                    if let p = data?.probabilities { probabilityTable(p) }
 58                }
 59                section("Geomagnetic activity (Kp)", note: "How disturbed Earth's magnetic field is, from 0 (quiet) to 9. At 5 or more it is a storm and the aurora reaches lower latitudes.") { kpChart }
 60
 61                Text("The Sun as NASA's Solar Dynamics Observatory sees it now, and the space weather it sends our way: flares, magnetic storms and the aurora.")
 62                    .font(.footnote).dimmed(0.6).padding(.top, 24)
 63                Text("Space weather data: NOAA Space Weather Prediction Center. Coastlines: Natural Earth.").font(.caption2).dimmed(0.5).padding(.vertical, 16)
 64            }
 65            .padding(.horizontal, 20)
 66            .readableWidth()
 67        }
 68        .foregroundStyle(ink)
 69        .background(background.ignoresSafeArea())
 70        .refreshable { await load(force: true) }
 71        .task { await load(force: false) }
 72        .task(id: "\(wavelength.rawValue) \(imageID)") { await loadImage() }
 73    }
 74
 75    private func load(force: Bool) async {
 76        if data == nil { show(await SunFeeds.lastSaved()) }
 77        if force { imageID = .now }
 78        let result = await SunFeeds.load(force: force)
 79        show(result?.snapshot)
 80        failed = !(result?.complete ?? false)
 81    }
 82
 83    private func show(_ snapshot: SunSnapshot?) {
 84        guard let snapshot else { return }
 85        data = snapshot
 86        summary = SunFeeds.summary(snapshot)
 87    }
 88
 89    /// An SDO image, with the time it was saved when it comes from the cache.
 90    private struct Picture {
 91        var wavelength: SolarImage
 92        var image: UIImage
 93        var loaded: Date
 94        var saved: Date?
 95    }
 96
 97    /// SDO sends no-store, so the last image of each wavelength is saved to Caches for use offline.
 98    /// A fresh image of the same wavelength under 5 minutes old is kept.
 99    private func loadImage() async {
100        let w = wavelength
101        if let p = picture, p.wavelength == w, p.saved == nil, p.loaded > imageID, Date.now.timeIntervalSince(p.loaded) < 300 { return }
102        let file = URL.cachesDirectory.appending(path: "SDO/\(w.rawValue).jpg")
103        picture = nil
104        imageFailed = false
105        if let (data, response) = try? await URLSession.shared.data(from: w.url(size: 1024)),
106           (response as? HTTPURLResponse)?.statusCode == 200, let image = UIImage(data: data) {
107            picture = Picture(wavelength: w, image: image, loaded: .now)
108            try? FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
109            try? data.write(to: file, options: .atomic)
110            return
111        }
112        guard !Task.isCancelled else { return }
113        if let image = UIImage(contentsOfFile: file.path(percentEncoded: false)) {
114            let saved = (try? file.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
115            picture = Picture(wavelength: w, image: image, loaded: .now, saved: saved ?? .distantPast)
116        } else {
117            imageFailed = true
118        }
119    }
120
121    private var image: some View {
122        VStack(alignment: .leading, spacing: 4) {
123            Group {
124                if let picture {
125                    Image(uiImage: picture.image).resizable().scaledToFit()
126                } else if imageFailed {
127                    Color.black.overlay(Text("Image unavailable").font(.footnote).dimmed(0.6))
128                } else {
129                    Color.black.overlay(ProgressView().tint(ink))
130                }
131            }
132            .aspectRatio(1, contentMode: .fit)
133            .accessibilityLabel("The Sun at \(wavelength.name)")
134            if let saved = picture?.saved {
135                Text("Couldn't load a new image. This one was saved \(saved.formatted(.relative(presentation: .named))).")
136                    .font(.caption2).foregroundStyle(accent)
137            }
138        }
139    }
140
141    /// Three columns, stacked at accessibility text sizes.
142    private func scalesStrip(_ s: NOAAScales) -> some View {
143        let stacked = typeSize.isAccessibilitySize
144        let layout = stacked ? AnyLayout(VStackLayout(alignment: .leading, spacing: 12)) : AnyLayout(HStackLayout(alignment: .top, spacing: 12))
145        return layout {
146            scale("R", "Radio", s.current.r, s.past24h.r)
147            Rectangle().fill(ink.opacity(0.25)).frame(width: stacked ? nil : 0.5, height: stacked ? 0.5 : nil)
148            scale("S", "Radiation", s.current.s, s.past24h.s)
149            Rectangle().fill(ink.opacity(0.25)).frame(width: stacked ? nil : 0.5, height: stacked ? 0.5 : nil)
150            scale("G", "Geomagnetic", s.current.g, s.past24h.g)
151        }
152        .fixedSize(horizontal: false, vertical: true)
153    }
154
155    private func scale(_ letter: String, _ title: String, _ level: Int, _ past: Int) -> some View {
156        VStack(alignment: .leading, spacing: 2) {
157            Text(title).font(.caption).dimmed(0.6)
158            Text("\(letter)\(level)").font(.title.weight(.medium).monospacedDigit())
159                .foregroundStyle(level >= 1 ? accent : ink)
160            Text(NOAAScales.levelName(level)).font(.footnote)
161            Text("24 h max \(letter)\(past)").font(.caption).dimmed(0.6)
162        }
163        .frame(maxWidth: .infinity, alignment: .leading)
164        .accessibilityElement(children: .combine)
165    }
166
167    /// Flare and proton chances by UT day, under the X-ray chart.
168    private func probabilityTable(_ p: SolarProbabilities) -> some View {
169        let utc = TimeZone(identifier: "UTC")!
170        var weekday = Date.FormatStyle().weekday(.abbreviated)
171        weekday.timeZone = utc
172        let issued = Date.VerbatimFormatStyle(
173            format: "\(day: .defaultDigits) \(month: .abbreviated) \(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased)):\(minute: .twoDigits)",
174            locale: .current, timeZone: utc, calendar: Calendar(identifier: .gregorian)
175        )
176        let days = p.days.indices.map { p.issued.addingTimeInterval(Double($0) * 86_400).formatted(weekday) }
177        let rows: [(String, KeyPath<SolarProbabilities.Day, Int>)] = [("C-class flare", \.c), ("M-class flare", \.m), ("X-class flare", \.x), ("Proton event", \.proton)]
178        return VStack(alignment: .leading, spacing: 6) {
179            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
180                GridRow {
181                    Text("Chance")
182                    ForEach(days.indices, id: \.self) { Text(days[$0]).gridColumnAlignment(.trailing) }
183                }
184                .font(.caption2).dimmed(0.6)
185                ForEach(rows, id: \.0) { row in
186                    GridRow {
187                        Text(row.0).dimmed(0.7)
188                        ForEach(p.days.indices, id: \.self) { Text("\(p.days[$0][keyPath: row.1])%").monospacedDigit() }
189                    }
190                    .font(.subheadline)
191                }
192            }
193            Text("NOAA SWPC forecast dated \(p.issued.formatted(issued)) UT, by UT day. A proton event is a solar radiation storm of S1 or more.")
194                .font(.caption2).dimmed(0.6)
195        }
196        .padding(.top, 8)
197    }
198
199    private func section(_ title: String, note: String? = nil, @ViewBuilder content: () -> some View) -> some View {
200        VStack(alignment: .leading, spacing: 10) {
201            Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
202            if let note { Text(note).font(.footnote).dimmed(0.75) }
203            content()
204        }
205        .padding(.top, 24)
206    }
207
208    /// Last known location, or Chicago.
209    private var observer: (lat: Double, lon: Double) {
210        let c = CLLocationManager().location?.coordinate
211        return (c?.latitude ?? 41.8781, c?.longitude ?? -87.6298)
212    }
213
214    @ViewBuilder
215    private var auroraMap: some View {
216        if let ovation {
217            AuroraMap(cells: ovation.cells.map { GridValue(lon: $0.lon, lat: $0.lat, value: $0.probability) }, observer: observer, ink: ink)
218                .accessibilityElement()
219                .accessibilityLabel(auroraDescription(ovation))
220            Text("Chance of visible aurora overhead, OVATION model, for \(ovation.forecastTime.formatted(date: .omitted, time: .shortened)). Brighter green is more likely.")
221                .font(.caption2).dimmed(0.6)
222        } else {
223            Text("Loading").font(.footnote).dimmed(0.6)
224        }
225    }
226
227    /// Highest aurora probability at or poleward of the observer's latitude in their hemisphere.
228    private func auroraDescription(_ o: Ovation) -> String {
229        let lat = observer.lat
230        let near = o.cells.filter { abs($0.lat - lat) <= 5 && (($0.lat >= 0) == (lat >= 0)) }.map(\.probability).max() ?? 0
231        return "Aurora forecast map. Highest chance within 5 degrees of your latitude: \(Int(near)) percent."
232    }
233
234    @ViewBuilder
235    private var flares: some View {
236        if let last = xray.last {
237            let peak = xray.max { $0.flux < $1.flux }!
238            HStack(alignment: .firstTextBaseline, spacing: 16) {
239                stat("Now", XRay.flareClass(last.flux))
240                stat("24 h peak", XRay.flareClass(peak.flux))
241                stat("Peak at", peak.time.formatted(date: .omitted, time: .shortened))
242            }
243            Chart {
244                ForEach(xray) { s in
245                    LineMark(x: .value("Time", s.time), y: .value("Flux", s.flux))
246                        .foregroundStyle(ink)
247                        .lineStyle(StrokeStyle(lineWidth: 1))
248                }
249                ForEach([("C", 1e-6), ("M", 1e-5), ("X", 1e-4)], id: \.0) { c in
250                    RuleMark(y: .value("Class", c.1))
251                        .foregroundStyle(c.0 == "C" ? ink.opacity(0.25) : accent.opacity(0.6))
252                        .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
253                        .annotation(position: .top, alignment: .leading) {
254                            Text(c.0).font(.caption2).dimmed(0.7)
255                        }
256                }
257            }
258            .chartYScale(domain: 1e-8...1e-3, type: .log)
259            .chartYAxis(.hidden)
260            .chartXAxis {
261                AxisMarks(values: .stride(by: .hour, count: 6)) { _ in
262                    AxisGridLine().foregroundStyle(ink.opacity(0.15))
263                    AxisValueLabel(format: .dateTime.hour()).foregroundStyle(ink.opacity(0.6))
264                }
265            }
266            .frame(height: 160)
267        } else {
268            Text("Loading").font(.footnote).dimmed(0.6)
269        }
270    }
271
272    @ViewBuilder
273    private var kpChart: some View {
274        if kp.isEmpty {
275            Text("Loading").font(.footnote).dimmed(0.6)
276        } else {
277            Chart(kp) { bar in
278                BarMark(x: .value("Start", bar.start, unit: .hour), y: .value("Kp", bar.kp), width: .ratio(0.8))
279                    .foregroundStyle(bar.kp >= 5 ? accent : ink.opacity(bar.predicted ? 0.3 : 0.85))
280            }
281            .chartYScale(domain: 0...9)
282            .chartYAxis {
283                AxisMarks(values: [0, 3, 5, 7, 9]) { v in
284                    AxisGridLine().foregroundStyle(ink.opacity(0.15))
285                    AxisValueLabel().foregroundStyle(ink.opacity(0.6))
286                }
287            }
288            .chartXAxis {
289                AxisMarks(values: .stride(by: .day)) { _ in
290                    AxisGridLine().foregroundStyle(ink.opacity(0.15))
291                    AxisValueLabel(format: .dateTime.weekday(.abbreviated)).foregroundStyle(ink.opacity(0.6))
292                }
293            }
294            .frame(height: 140)
295            Text("Solid: observed. Faint: forecast. Red: storm level (Kp 5 or more).")
296                .font(.caption2).dimmed(0.6)
297        }
298    }
299
300    private func stat(_ label: String, _ value: String) -> some View {
301        VStack(alignment: .leading, spacing: 2) {
302            Text(label).font(.caption2).dimmed(0.6)
303            Text(value).font(.title3.weight(.medium).monospacedDigit())
304        }
305    }
306}
307
308/// NOAA feeds for the Sun tab and the 3D view's caption. The last complete set is saved to Application Support
309/// and stands in for any feed that fails to load.
310@MainActor
311enum SunFeeds {
312    private static let file = URL.applicationSupportDirectory.appending(path: "SunSnapshot.json")
313    private static var saved: SunSnapshot?
314
315    /// NOAA updates these every minute or so; a load within 5 minutes of the last one reuses it.
316    /// `complete` is false when any feed came from the saved set, whose `fetched` date the result then carries.
317    /// `nil` only when nothing loaded and nothing was saved.
318    static func load(force: Bool) async -> (snapshot: SunSnapshot, complete: Bool)? {
319        if !force, let hit: SunSnapshot = FeedMemo.get("sun", maxAge: 300) { return (hit, true) }
320        async let k = try? SpaceWeather.fetchKpBars()
321        async let x = try? XRay.fetch()
322        async let o = try? Ovation.fetch()
323        async let s = try? NOAAScales.fetch()
324        async let p = try? SolarProbabilities.fetch()
325        let (kp, xray, ovation, scales, probabilities) = await (k, x, o, s, p)
326        if let kp, let xray, let ovation, let scales, let probabilities {
327            let fresh = SunSnapshot(fetched: .now, kp: kp, xray: xray, ovation: ovation, scales: scales, probabilities: probabilities)
328            FeedMemo.set("sun", fresh)
329            saved = fresh
330            let file = file
331            Task.detached(priority: .utility) { try? fresh.save(to: file) }
332            return (fresh, true)
333        }
334        let old = await lastSaved()
335        guard kp != nil || xray != nil || ovation != nil || scales != nil || probabilities != nil || old != nil else { return nil }
336        let merged = SunSnapshot(
337            fetched: old?.fetched ?? .now,
338            kp: kp ?? old?.kp ?? [],
339            xray: xray ?? old?.xray ?? [],
340            ovation: ovation ?? old?.ovation,
341            scales: scales ?? old?.scales,
342            probabilities: probabilities ?? old?.probabilities
343        )
344        return (merged, false)
345    }
346
347    /// The last complete set, read from disk on first use.
348    static func lastSaved() async -> SunSnapshot? {
349        if saved == nil {
350            let file = file
351            saved = await Task.detached { SunSnapshot.load(from: file) }.value
352        }
353        return saved
354    }
355
356    /// Summary for the last known location, or Chicago.
357    static func summary(_ s: SunSnapshot, at date: Date = .now) -> String? {
358        let observer = CLLocationManager().location.map { Observer(latitude: $0.coordinate.latitude, longitude: $0.coordinate.longitude) } ?? SkyModel.fallback
359        let night = (try? Tonight(at: date, from: observer)).map { DateInterval(start: $0.start, end: $0.end) } ?? DateInterval(start: date, duration: 0)
360        return SunSummary.text(
361            scales: s.scales, kp: s.kp, probabilities: s.probabilities, xray: s.xray,
362            geomagneticLatitude: Geomagnetic.latitude(observer), night: night, at: date
363        )
364    }
365
366    /// Source and age: "NOAA SWPC, 21:13", or "NOAA SWPC data from 3 hours ago" once it is over 15 minutes old.
367    static func source(_ s: SunSnapshot) -> String {
368        Date.now.timeIntervalSince(s.fetched) < 900
369            ? "NOAA SWPC, \(s.fetched.formatted(date: .omitted, time: .shortened))"
370            : "NOAA SWPC data from \(s.fetched.formatted(.relative(presentation: .named)))"
371    }
372}