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

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