M2: Sun tab !7
6 files changed, +345 −39
Layout: unified · split
App/Sources/MoonriseApp.swift +5 −2
| @@ -10,8 +10,11 @@ struct MoonriseApp: App { | ||
| 10 | 10 | |
| 11 | 11 | var body: some Scene { |
| 12 | 12 | 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) | |
| 15 | 18 | } |
| 16 | 19 | } |
| 17 | 20 | } |
App/Sources/SkyScreen.swift +4 −4
| @@ -9,6 +9,7 @@ struct SkyScreen: View { | ||
| 9 | 9 | @State private var altitude = 45.0 |
| 10 | 10 | @State private var fieldOfView = 100.0 |
| 11 | 11 | @State private var night = false |
| 12 | @State private var showToday = false | |
| 12 | 13 | @State private var selection: SkyObject? |
| 13 | 14 | @State private var details: BodyDetails? |
| 14 | 15 | @GestureState private var drag: CGSize = .zero |
| @@ -38,15 +39,14 @@ struct SkyScreen: View { | ||
| 38 | 39 | header |
| 39 | 40 | if let selection { card(for: selection).padding(.horizontal, 16).padding(.top, 8) } |
| 40 | 41 | Spacer() |
| 42 | TodayBar(model: model, ink: ink, background: style.ground) { showToday = true } | |
| 41 | 43 | } |
| 42 | 44 | } |
| 43 | 45 | } |
| 44 | 46 | .foregroundStyle(ink) |
| 45 | .sheet(isPresented: .constant(true)) { | |
| 47 | .sheet(isPresented: $showToday) { | |
| 46 | 48 | 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]) | |
| 50 | 50 | } |
| 51 | 51 | .onChange(of: model.offsetHours) { select(selection) } |
| 52 | 52 | .task { |
App/Sources/SunScreen.swift added +150
| @@ -0,0 +1,150 @@ | ||
| 1 | import Charts | |
| 2 | import Feeds | |
| 3 | import SwiftUI | |
| 4 | ||
| 5 | struct 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 | ||
| 2 | 2 | import SkyEngine |
| 3 | 3 | import SwiftUI |
| 4 | 4 | |
| 5 | /// Pull-up sheet over the sky: time scrubber, then tonight's summary. | |
| 5 | /// Tonight's details: moon, planets, sun and space weather. | |
| 6 | 6 | struct TodaySheet: View { |
| 7 | 7 | @Bindable var model: SkyModel |
| 8 | 8 | var ink: Color |
| @@ -11,8 +11,6 @@ struct TodaySheet: View { | ||
| 11 | 11 | var body: some View { |
| 12 | 12 | ScrollView { |
| 13 | 13 | VStack(alignment: .leading, spacing: 0) { |
| 14 | scrubber | |
| 15 | Text(summary).font(.system(size: 13)).opacity(0.7).padding(.bottom, 16) | |
| 16 | 14 | if let t = model.tonight { |
| 17 | 15 | section("Moon") { moon(t) } |
| 18 | 16 | section("Planets tonight") { planets(t) } |
| @@ -27,36 +25,6 @@ struct TodaySheet: View { | ||
| 27 | 25 | } |
| 28 | 26 | .foregroundStyle(ink) |
| 29 | 27 | .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) | |
| 60 | 28 | } |
| 61 | 29 | |
| 62 | 30 | private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View { |
| @@ -140,3 +108,59 @@ struct TodaySheet: View { | ||
| 140 | 108 | ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Int(((azimuth + 22.5) / 45).rounded(.down)) % 8] |
| 141 | 109 | } |
| 142 | 110 | } |
| 111 | ||
| 112 | /// Bottom bar on the sky: time scrubber and a one-line summary that opens the Today sheet. | |
| 113 | struct 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 @@ | ||
| 1 | import 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." | |
| 5 | public 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). | |
| 40 | public 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 | ||
| 47 | extension 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. | |
| 60 | public 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 | ||
| 67 | public 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 @@ | ||
| 1 | import Foundation | |
| 2 | import 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 | } | |