App/Sources/SunScreen.swift
192 lines · 8948 bytes
5 symbols in this file
1import Charts
2import CoreLocation
3import Feeds
4import SkyRenderer
5import SwiftUI
6
7struct SunScreen: View {
8 @State private var wavelength = SolarImage.corona
9 @State private var kp: [KpBar] = []
10 @State private var xray: [XRaySample] = []
11 @State private var ovation: Ovation?
12 @State private var failed = false
13 @State private var imageID = Date.now
14
15 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
16 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
17 private let background = Color.black
18
19 var body: some View {
20 ScrollView {
21 VStack(alignment: .leading, spacing: 0) {
22 Text("Sun").font(.title.weight(.medium))
23 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
24 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.")
25 .font(.subheadline).dimmed(0.75).padding(.bottom, 16)
26
27 image
28 Picker("Wavelength", selection: $wavelength) {
29 ForEach(SolarImage.allCases) { Text($0.name).tag($0) }
30 }
31 .pickerStyle(.segmented)
32 .padding(.top, 12)
33 Text(wavelength.caption).font(.footnote).dimmed(0.75).padding(.top, 8)
34 Text(SolarImage.credit).font(.caption2).dimmed(0.5).padding(.top, 4)
35
36 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 }
37 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.") { flares }
38 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 }
39
40 if failed {
41 Text("Couldn't load space weather. Pull to retry.").font(.footnote).foregroundStyle(accent).padding(.top, 12)
42 }
43 Text("Space weather data: NOAA Space Weather Prediction Center. Coastlines: Natural Earth.").font(.caption2).dimmed(0.5).padding(.vertical, 16)
44 }
45 .padding(.horizontal, 20)
46 .readableWidth()
47 }
48 .foregroundStyle(ink)
49 .background(background.ignoresSafeArea())
50 .refreshable { await load(force: true) }
51 .task { await load(force: false) }
52 }
53
54 /// NOAA updates these every minute or so; a visit within 5 minutes of the last load reuses it.
55 private func load(force: Bool) async {
56 if !force, let cached: ([KpBar], [XRaySample], Ovation?) = FeedMemo.get("sun", maxAge: 300) {
57 (kp, xray, ovation) = cached
58 return
59 }
60 imageID = .now
61 async let k = try? SpaceWeather.fetchKpBars()
62 async let x = try? XRay.fetch()
63 async let o = try? Ovation.fetch()
64 let (kpResult, xrayResult, ovationResult) = await (k, x, o)
65 kp = kpResult ?? kp
66 xray = xrayResult ?? xray
67 ovation = ovationResult ?? ovation
68 failed = kpResult == nil || xrayResult == nil || ovationResult == nil
69 if !failed { FeedMemo.set("sun", (kp, xray, ovation)) }
70 }
71
72 private var image: some View {
73 AsyncImage(url: wavelength.url(size: 1024)) { phase in
74 switch phase {
75 case .success(let img): img.resizable().scaledToFit()
76 case .failure: Color.black.overlay(Text("Image unavailable").font(.footnote).dimmed(0.6))
77 default: Color.black.overlay(ProgressView().tint(ink))
78 }
79 }
80 .id(imageID)
81 .aspectRatio(1, contentMode: .fit)
82 .accessibilityLabel("The Sun at \(wavelength.name)")
83 }
84
85 private func section(_ title: String, note: String? = nil, @ViewBuilder content: () -> some View) -> some View {
86 VStack(alignment: .leading, spacing: 10) {
87 Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
88 if let note { Text(note).font(.footnote).dimmed(0.75) }
89 content()
90 }
91 .padding(.top, 24)
92 }
93
94 /// Last known location, or Chicago.
95 private var observer: (lat: Double, lon: Double) {
96 let c = CLLocationManager().location?.coordinate
97 return (c?.latitude ?? 41.8781, c?.longitude ?? -87.6298)
98 }
99
100 @ViewBuilder
101 private var auroraMap: some View {
102 if let ovation {
103 AuroraMap(cells: ovation.cells.map { GridValue(lon: $0.lon, lat: $0.lat, value: $0.probability) }, observer: observer, ink: ink)
104 .accessibilityElement()
105 .accessibilityLabel(auroraDescription(ovation))
106 Text("Chance of visible aurora overhead, OVATION model, for \(ovation.forecastTime.formatted(date: .omitted, time: .shortened)). Brighter green is more likely.")
107 .font(.caption2).dimmed(0.6)
108 } else {
109 Text("Loading").font(.footnote).dimmed(0.6)
110 }
111 }
112
113 /// Highest aurora probability at or poleward of the observer's latitude in their hemisphere.
114 private func auroraDescription(_ o: Ovation) -> String {
115 let lat = observer.lat
116 let near = o.cells.filter { abs($0.lat - lat) <= 5 && (($0.lat >= 0) == (lat >= 0)) }.map(\.probability).max() ?? 0
117 return "Aurora forecast map. Highest chance within 5 degrees of your latitude: \(Int(near)) percent."
118 }
119
120 @ViewBuilder
121 private var flares: some View {
122 if let last = xray.last {
123 let peak = xray.max { $0.flux < $1.flux }!
124 HStack(alignment: .firstTextBaseline, spacing: 16) {
125 stat("Now", XRay.flareClass(last.flux))
126 stat("24 h peak", XRay.flareClass(peak.flux))
127 stat("Peak at", peak.time.formatted(date: .omitted, time: .shortened))
128 }
129 Chart {
130 ForEach(xray) { s in
131 LineMark(x: .value("Time", s.time), y: .value("Flux", s.flux))
132 .foregroundStyle(ink)
133 .lineStyle(StrokeStyle(lineWidth: 1))
134 }
135 ForEach([("C", 1e-6), ("M", 1e-5), ("X", 1e-4)], id: \.0) { c in
136 RuleMark(y: .value("Class", c.1))
137 .foregroundStyle(c.0 == "C" ? ink.opacity(0.25) : accent.opacity(0.6))
138 .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
139 .annotation(position: .top, alignment: .leading) {
140 Text(c.0).font(.caption2).dimmed(0.7)
141 }
142 }
143 }
144 .chartYScale(domain: 1e-8...1e-3, type: .log)
145 .chartYAxis(.hidden)
146 .chartXAxis {
147 AxisMarks(values: .stride(by: .hour, count: 6)) { _ in
148 AxisGridLine().foregroundStyle(ink.opacity(0.15))
149 AxisValueLabel(format: .dateTime.hour()).foregroundStyle(ink.opacity(0.6))
150 }
151 }
152 .frame(height: 160)
153 } else {
154 Text("Loading").font(.footnote).dimmed(0.6)
155 }
156 }
157
158 @ViewBuilder
159 private var kpChart: some View {
160 if kp.isEmpty {
161 Text("Loading").font(.footnote).dimmed(0.6)
162 } else {
163 Chart(kp) { bar in
164 BarMark(x: .value("Start", bar.start, unit: .hour), y: .value("Kp", bar.kp), width: .ratio(0.8))
165 .foregroundStyle(bar.kp >= 5 ? accent : ink.opacity(bar.predicted ? 0.3 : 0.85))
166 }
167 .chartYScale(domain: 0...9)
168 .chartYAxis {
169 AxisMarks(values: [0, 3, 5, 7, 9]) { v in
170 AxisGridLine().foregroundStyle(ink.opacity(0.15))
171 AxisValueLabel().foregroundStyle(ink.opacity(0.6))
172 }
173 }
174 .chartXAxis {
175 AxisMarks(values: .stride(by: .day)) { _ in
176 AxisGridLine().foregroundStyle(ink.opacity(0.15))
177 AxisValueLabel(format: .dateTime.weekday(.abbreviated)).foregroundStyle(ink.opacity(0.6))
178 }
179 }
180 .frame(height: 140)
181 Text("Solid: observed. Faint: forecast. Red: storm level (Kp 5 or more).")
182 .font(.caption2).dimmed(0.6)
183 }
184 }
185
186 private func stat(_ label: String, _ value: String) -> some View {
187 VStack(alignment: .leading, spacing: 2) {
188 Text(label).font(.caption2).dimmed(0.6)
189 Text(value).font(.title3.weight(.medium).monospacedDigit())
190 }
191 }
192}