App/Sources/SunScreen.swift
372 lines · 18300 bytes
16 symbols in this file
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}