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}