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

04b2b1d914788057bcd20945b876847386eb8f65
moonrise/App/Sources/SunScreen.swift history · blame · raw

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}