M2: Sun tab !7

merged merged by cmc on 2026-10-18 01:54 UTC · krz/moonrise:m2-sun into main

6 files changed, +345 −39

Layout: unified · split

App/Sources/MoonriseApp.swift +5 −2
@@ -10,8 +10,11 @@ struct MoonriseApp: App {
1010
1111 var body: some Scene {
1212 WindowGroup {
13 SkyScreen()
14 .preferredColorScheme(.dark)
13 TabView {
14 Tab("Sky", systemImage: "sparkles") { SkyScreen() }
15 Tab("Sun", systemImage: "sun.max") { SunScreen() }
16 }
17 .preferredColorScheme(.dark)
1518 }
1619 }
1720}
App/Sources/SkyScreen.swift +4 −4
@@ -9,6 +9,7 @@ struct SkyScreen: View {
99 @State private var altitude = 45.0
1010 @State private var fieldOfView = 100.0
1111 @State private var night = false
12 @State private var showToday = false
1213 @State private var selection: SkyObject?
1314 @State private var details: BodyDetails?
1415 @GestureState private var drag: CGSize = .zero
@@ -38,15 +39,14 @@ struct SkyScreen: View {
3839 header
3940 if let selection { card(for: selection).padding(.horizontal, 16).padding(.top, 8) }
4041 Spacer()
42 TodayBar(model: model, ink: ink, background: style.ground) { showToday = true }
4143 }
4244 }
4345 }
4446 .foregroundStyle(ink)
45 .sheet(isPresented: .constant(true)) {
47 .sheet(isPresented: $showToday) {
4648 TodaySheet(model: model, ink: ink, background: style.ground)
47 .presentationDetents([.height(110), .medium, .large])
48 .presentationBackgroundInteraction(.enabled(upThrough: .medium))
49 .interactiveDismissDisabled()
49 .presentationDetents([.medium, .large])
5050 }
5151 .onChange(of: model.offsetHours) { select(selection) }
5252 .task {
App/Sources/SunScreen.swift added +150
@@ -0,0 +1,150 @@
1import Charts
2import Feeds
3import SwiftUI
4
5struct SunScreen: View {
6 @State private var wavelength = SolarImage.corona
7 @State private var kp: [KpBar] = []
8 @State private var xray: [XRaySample] = []
9 @State private var failed = false
10 @State private var imageID = Date.now
11
12 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
13 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
14 private let background = Color(red: 0.043, green: 0.078, blue: 0.141)
15
16 var body: some View {
17 ScrollView {
18 VStack(alignment: .leading, spacing: 0) {
19 Text("Sun").font(.system(size: 28, weight: .medium))
20 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 16)
21
22 image
23 Picker("Wavelength", selection: $wavelength) {
24 ForEach(SolarImage.allCases) { Text($0.name).tag($0) }
25 }
26 .pickerStyle(.segmented)
27 .padding(.top, 12)
28 Text(wavelength.caption).font(.system(size: 13)).opacity(0.75).padding(.top, 8)
29 Text(SolarImage.credit).font(.system(size: 10)).opacity(0.5).padding(.top, 4)
30
31 section("Solar flares, last 24 h") { flares }
32 section("Geomagnetic activity (Kp)") { kpChart }
33
34 if failed {
35 Text("Couldn't load space weather. Pull to retry.").font(.system(size: 13)).foregroundStyle(accent).padding(.top, 12)
36 }
37 Text("Space weather data: NOAA Space Weather Prediction Center.").font(.system(size: 10)).opacity(0.5).padding(.vertical, 16)
38 }
39 .padding(.horizontal, 20)
40 }
41 .foregroundStyle(ink)
42 .background(background.ignoresSafeArea())
43 .refreshable { await load() }
44 .task { await load() }
45 }
46
47 private func load() async {
48 imageID = .now
49 async let k = try? SpaceWeather.fetchKpBars()
50 async let x = try? XRay.fetch()
51 let (kpResult, xrayResult) = await (k, x)
52 kp = kpResult ?? kp
53 xray = xrayResult ?? xray
54 failed = kpResult == nil || xrayResult == nil
55 }
56
57 private var image: some View {
58 AsyncImage(url: wavelength.url(size: 1024)) { phase in
59 switch phase {
60 case .success(let img): img.resizable().scaledToFit()
61 case .failure: Color.black.overlay(Text("Image unavailable").font(.system(size: 13)).opacity(0.6))
62 default: Color.black.overlay(ProgressView().tint(ink))
63 }
64 }
65 .id(imageID)
66 .aspectRatio(1, contentMode: .fit)
67 .accessibilityLabel("The Sun at \(wavelength.name)")
68 }
69
70 private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
71 VStack(alignment: .leading, spacing: 10) {
72 Text(title).font(.system(size: 11, weight: .medium)).textCase(.uppercase).tracking(0.8).opacity(0.6)
73 content()
74 }
75 .padding(.top, 24)
76 }
77
78 @ViewBuilder
79 private var flares: some View {
80 if let last = xray.last {
81 let peak = xray.max { $0.flux < $1.flux }!
82 HStack(alignment: .firstTextBaseline, spacing: 16) {
83 stat("Now", XRay.flareClass(last.flux))
84 stat("24 h peak", XRay.flareClass(peak.flux))
85 stat("Peak at", peak.time.formatted(date: .omitted, time: .shortened))
86 }
87 Chart {
88 ForEach(xray) { s in
89 LineMark(x: .value("Time", s.time), y: .value("Flux", s.flux))
90 .foregroundStyle(ink)
91 .lineStyle(StrokeStyle(lineWidth: 1))
92 }
93 ForEach([("C", 1e-6), ("M", 1e-5), ("X", 1e-4)], id: \.0) { c in
94 RuleMark(y: .value("Class", c.1))
95 .foregroundStyle(c.0 == "C" ? ink.opacity(0.25) : accent.opacity(0.6))
96 .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
97 .annotation(position: .top, alignment: .leading) {
98 Text(c.0).font(.system(size: 10)).opacity(0.7)
99 }
100 }
101 }
102 .chartYScale(domain: 1e-8...1e-3, type: .log)
103 .chartYAxis(.hidden)
104 .chartXAxis {
105 AxisMarks(values: .stride(by: .hour, count: 6)) { _ in
106 AxisGridLine().foregroundStyle(ink.opacity(0.15))
107 AxisValueLabel(format: .dateTime.hour()).foregroundStyle(ink.opacity(0.6))
108 }
109 }
110 .frame(height: 160)
111 } else {
112 Text("Loading").font(.system(size: 13)).opacity(0.6)
113 }
114 }
115
116 @ViewBuilder
117 private var kpChart: some View {
118 if kp.isEmpty {
119 Text("Loading").font(.system(size: 13)).opacity(0.6)
120 } else {
121 Chart(kp) { bar in
122 BarMark(x: .value("Start", bar.start, unit: .hour), y: .value("Kp", bar.kp), width: .ratio(0.8))
123 .foregroundStyle(bar.kp >= 5 ? accent : ink.opacity(bar.predicted ? 0.3 : 0.85))
124 }
125 .chartYScale(domain: 0...9)
126 .chartYAxis {
127 AxisMarks(values: [0, 3, 5, 7, 9]) { v in
128 AxisGridLine().foregroundStyle(ink.opacity(0.15))
129 AxisValueLabel().foregroundStyle(ink.opacity(0.6))
130 }
131 }
132 .chartXAxis {
133 AxisMarks(values: .stride(by: .day)) { _ in
134 AxisGridLine().foregroundStyle(ink.opacity(0.15))
135 AxisValueLabel(format: .dateTime.weekday(.abbreviated)).foregroundStyle(ink.opacity(0.6))
136 }
137 }
138 .frame(height: 140)
139 Text("Solid: observed. Faint: forecast. Red: storm level (Kp 5 or more).")
140 .font(.system(size: 11)).opacity(0.6)
141 }
142 }
143
144 private func stat(_ label: String, _ value: String) -> some View {
145 VStack(alignment: .leading, spacing: 2) {
146 Text(label).font(.system(size: 10)).opacity(0.6)
147 Text(value).font(.system(size: 20, weight: .medium).monospacedDigit())
148 }
149 }
150}
App/Sources/TodaySheet.swift +57 −33
@@ -2,7 +2,7 @@ import Feeds
22import SkyEngine
33import SwiftUI
44
5/// Pull-up sheet over the sky: time scrubber, then tonight's summary.
5/// Tonight's details: moon, planets, sun and space weather.
66struct TodaySheet: View {
77 @Bindable var model: SkyModel
88 var ink: Color
@@ -11,8 +11,6 @@ struct TodaySheet: View {
1111 var body: some View {
1212 ScrollView {
1313 VStack(alignment: .leading, spacing: 0) {
14 scrubber
15 Text(summary).font(.system(size: 13)).opacity(0.7).padding(.bottom, 16)
1614 if let t = model.tonight {
1715 section("Moon") { moon(t) }
1816 section("Planets tonight") { planets(t) }
@@ -27,36 +25,6 @@ struct TodaySheet: View {
2725 }
2826 .foregroundStyle(ink)
2927 .presentationBackground(background)
30 .task {
31 while !Task.isCancelled {
32 await model.refreshSpaceWeather()
33 try? await Task.sleep(for: .seconds(600))
34 }
35 }
36 }
37
38 private var summary: String {
39 var parts: [String] = []
40 if let t = model.tonight {
41 parts.append("Moon \(Int((t.moonIllumination * 100).rounded()))%")
42 let up = t.planets.filter { $0.best != nil }.map { $0.body.rawValue.capitalized }
43 parts.append(up.isEmpty ? "No bright planets tonight" : up.joined(separator: ", "))
44 }
45 if let w = model.spaceWeather { parts.append(String(format: "Kp %.1f", w.kpNow)) }
46 return parts.joined(separator: " · ")
47 }
48
49 private var scrubber: some View {
50 HStack(spacing: 12) {
51 Text(model.displayedDate.formatted(date: .omitted, time: .shortened))
52 .font(.system(size: 13).monospacedDigit())
53 .frame(width: 70, alignment: .leading)
54 Slider(value: $model.offsetHours, in: -12...12, step: 0.25).tint(ink)
55 Button("Now") { model.offsetHours = 0 }
56 .font(.system(size: 13, weight: .medium))
57 .opacity(model.offsetHours == 0 ? 0.4 : 1)
58 }
59 .padding(.bottom, 10)
6028 }
6129
6230 private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
@@ -140,3 +108,59 @@ struct TodaySheet: View {
140108 ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Int(((azimuth + 22.5) / 45).rounded(.down)) % 8]
141109 }
142110}
111
112/// Bottom bar on the sky: time scrubber and a one-line summary that opens the Today sheet.
113struct TodayBar: View {
114 @Bindable var model: SkyModel
115 var ink: Color
116 var background: Color
117 var onExpand: () -> Void
118
119 var body: some View {
120 VStack(alignment: .leading, spacing: 6) {
121 scrubber
122 Button(action: onExpand) {
123 HStack {
124 Text(summary).font(.system(size: 13)).opacity(0.75).lineLimit(1)
125 Spacer()
126 Image(systemName: "chevron.up").font(.system(size: 12, weight: .medium)).opacity(0.6)
127 }
128 }
129 .buttonStyle(.plain)
130 .accessibilityHint("Shows tonight's details")
131 }
132 .padding(.horizontal, 20)
133 .padding(.vertical, 10)
134 .background(background.opacity(0.92))
135 .task {
136 while !Task.isCancelled {
137 await model.refreshSpaceWeather()
138 try? await Task.sleep(for: .seconds(600))
139 }
140 }
141 }
142
143 private var summary: String {
144 var parts: [String] = []
145 if let t = model.tonight {
146 parts.append("Moon \(Int((t.moonIllumination * 100).rounded()))%")
147 let up = t.planets.filter { $0.best != nil }.map { $0.body.rawValue.capitalized }
148 parts.append(up.isEmpty ? "No bright planets tonight" : up.joined(separator: ", "))
149 }
150 if let w = model.spaceWeather { parts.append(String(format: "Kp %.1f", w.kpNow)) }
151 return parts.joined(separator: " · ")
152 }
153
154 private var scrubber: some View {
155 HStack(spacing: 12) {
156 Text(model.displayedDate.formatted(date: .omitted, time: .shortened))
157 .font(.system(size: 13).monospacedDigit())
158 .frame(width: 70, alignment: .leading)
159 Slider(value: $model.offsetHours, in: -12...12, step: 0.25).tint(ink)
160 Button("Now") { model.offsetHours = 0 }
161 .font(.system(size: 13, weight: .medium))
162 .opacity(model.offsetHours == 0 ? 0.4 : 1)
163 }
164 }
165
166}
Packages/MoonriseKit/Sources/Feeds/Sun.swift added +96
@@ -0,0 +1,96 @@
1import Foundation
2
3/// Latest full-disk images from NASA's Solar Dynamics Observatory.
4/// Credit: "Courtesy of NASA/SDO and the AIA, EVE, and HMI science teams."
5public enum SolarImage: String, CaseIterable, Sendable, Identifiable {
6 case corona = "0171"
7 case hotCorona = "0193"
8 case chromosphere = "0304"
9 case visible = "HMIIC"
10
11 public var id: String { rawValue }
12
13 public var name: String {
14 switch self {
15 case .corona: "171 Å"
16 case .hotCorona: "193 Å"
17 case .chromosphere: "304 Å"
18 case .visible: "Visible"
19 }
20 }
21
22 public var caption: String {
23 switch self {
24 case .corona: "Quiet corona and upper transition region, about 600,000 K. Shows coronal loops."
25 case .hotCorona: "Corona at about 1 million K and hot flare plasma. Coronal holes look dark."
26 case .chromosphere: "Chromosphere and transition region, about 50,000 K. Shows filaments and prominences."
27 case .visible: "The photosphere in visible light, where sunspots appear."
28 }
29 }
30
31 /// Sizes SDO publishes: 170, 256, 512, 1024, 2048, 3072, 4096.
32 public func url(size: Int = 1024) -> URL {
33 URL(string: "https://sdo.gsfc.nasa.gov/assets/img/latest/latest_\(size)_\(rawValue).jpg")!
34 }
35
36 public static let credit = "Courtesy of NASA/SDO and the AIA, EVE, and HMI science teams."
37}
38
39/// Three-hour planetary Kp, observed and predicted (SWPC 3-day forecast product).
40public struct KpBar: Sendable, Equatable, Identifiable {
41 public var start: Date
42 public var kp: Double
43 public var predicted: Bool
44 public var id: Date { start }
45}
46
47extension SpaceWeather {
48 public static func decodeKpBars(_ data: Data) throws -> [KpBar] {
49 try JSONDecoder().decode([Forecast].self, from: data).compactMap { row in
50 parse(row.time_tag).map { KpBar(start: $0, kp: row.kp, predicted: row.observed != "observed") }
51 }
52 }
53
54 public static func fetchKpBars(session: URLSession = .shared) async throws -> [KpBar] {
55 try decodeKpBars(await session.data(from: forecastURL).0)
56 }
57}
58
59/// GOES long-channel (0.1–0.8 nm) X-ray flux, which defines flare classes.
60public struct XRaySample: Sendable, Equatable, Identifiable {
61 public var time: Date
62 /// W/m².
63 public var flux: Double
64 public var id: Date { time }
65}
66
67public enum XRay {
68 public static let dayURL = URL(string: "https://services.swpc.noaa.gov/json/goes/primary/xrays-1-day.json")!
69
70 struct Row: Decodable {
71 var time_tag: String
72 var flux: Double?
73 var energy: String
74 }
75
76 public static func decode(_ data: Data) throws -> [XRaySample] {
77 try JSONDecoder().decode([Row].self, from: data).compactMap { row in
78 guard row.energy == "0.1-0.8nm", let flux = row.flux, flux > 0,
79 let time = try? Date(row.time_tag, strategy: .iso8601) else { return nil }
80 return XRaySample(time: time, flux: flux)
81 }
82 }
83
84 public static func fetch(session: URLSession = .shared) async throws -> [XRaySample] {
85 try decode(await session.data(from: dayURL).0)
86 }
87
88 /// Flare class for a flux, e.g. 1.5e-6 → "C1.5".
89 public static func flareClass(_ flux: Double) -> String {
90 let classes: [(String, Double)] = [("X", 1e-4), ("M", 1e-5), ("C", 1e-6), ("B", 1e-7), ("A", 1e-8)]
91 for (letter, base) in classes where flux >= base {
92 return String(format: "%@%.1f", letter, flux / base)
93 }
94 return String(format: "A%.1f", flux / 1e-8)
95 }
96}
Packages/MoonriseKit/Tests/FeedsTests/SunTests.swift added +33
@@ -0,0 +1,33 @@
1import Foundation
2import Testing
3@testable import Feeds
4
5@Test func flareClasses() {
6 #expect(XRay.flareClass(1.5261429098245571e-06) == "C1.5")
7 #expect(XRay.flareClass(6.587599727936322e-07) == "B6.6")
8 #expect(XRay.flareClass(2.3e-4) == "X2.3")
9 #expect(XRay.flareClass(4e-9) == "A0.4")
10}
11
12@Test func decodesLongChannelOnly() throws {
13 // Shape copied from xrays-6-hour.json on 2026-10-17.
14 let data = Data("""
15 [{"time_tag": "2026-10-17T17:43:00Z", "satellite": 18, "flux": 1.195e-08, "observed_flux": 3.2e-08, "electron_correction": 2.0e-08, "electron_contaminaton": true, "energy": "0.05-0.4nm"},
16 {"time_tag": "2026-10-17T17:43:00Z", "satellite": 18, "flux": 6.5876e-07, "observed_flux": 6.9e-07, "electron_correction": 3.1e-08, "electron_contaminaton": false, "energy": "0.1-0.8nm"},
17 {"time_tag": "2026-10-17T17:44:00Z", "satellite": 18, "flux": null, "energy": "0.1-0.8nm"}]
18 """.utf8)
19 let samples = try XRay.decode(data)
20 #expect(samples.count == 1)
21 #expect(samples[0].flux == 6.5876e-07)
22}
23
24@Test func kpBarsMarkPredicted() throws {
25 let bars = try SpaceWeather.decodeKpBars(forecast)
26 #expect(bars.count == 4)
27 #expect(bars.first?.predicted == false)
28 #expect(bars.last?.predicted == true)
29}
30
31@Test func solarImageURL() {
32 #expect(SolarImage.corona.url().absoluteString == "https://sdo.gsfc.nasa.gov/assets/img/latest/latest_1024_0171.jpg")
33}