Commit 6c4b28384d
Unsigned
Layout: unified · split
App/Sources/SunScreen.swift +10 −6
| @@ -13,6 +13,7 @@ struct SunScreen: View { | |||
| 13 | @State private var imageID = Date.now | 13 | @State private var imageID = Date.now |
| 14 | @State private var picture: Picture? | 14 | @State private var picture: Picture? |
| 15 | @State private var imageFailed = false | 15 | @State private var imageFailed = false |
| 16 | @Environment(\.dynamicTypeSize) private var typeSize | ||
| 16 | 17 | ||
| 17 | private var kp: [KpBar] { data?.kp ?? [] } | 18 | private var kp: [KpBar] { data?.kp ?? [] } |
| 18 | private var xray: [XRaySample] { data?.xray ?? [] } | 19 | private var xray: [XRaySample] { data?.xray ?? [] } |
| @@ -29,7 +30,7 @@ struct SunScreen: View { | |||
| 29 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12) | 30 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12) |
| 30 | if let summary, let data { | 31 | if let summary, let data { |
| 31 | Text(summary) | 32 | Text(summary) |
| 32 | Text(SunFeeds.source(data)).font(.caption2).dimmed(0.5).padding(.top, 4) | 33 | Text(SunFeeds.source(data)).font(.caption).dimmed(0.5).padding(.top, 4) |
| 33 | } else if !failed { | 34 | } else if !failed { |
| 34 | Text("Loading").font(.footnote).dimmed(0.6) | 35 | Text("Loading").font(.footnote).dimmed(0.6) |
| 35 | } | 36 | } |
| @@ -137,12 +138,15 @@ struct SunScreen: View { | |||
| 137 | } | 138 | } |
| 138 | } | 139 | } |
| 139 | 140 | ||
| 141 | /// Three columns, stacked at accessibility text sizes. | ||
| 140 | private func scalesStrip(_ s: NOAAScales) -> some View { | 142 | private func scalesStrip(_ s: NOAAScales) -> some View { |
| 141 | HStack(alignment: .top, spacing: 12) { | 143 | let stacked = typeSize.isAccessibilitySize |
| 144 | let layout = stacked ? AnyLayout(VStackLayout(alignment: .leading, spacing: 12)) : AnyLayout(HStackLayout(alignment: .top, spacing: 12)) | ||
| 145 | return layout { | ||
| 142 | scale("R", "Radio", s.current.r, s.past24h.r) | 146 | scale("R", "Radio", s.current.r, s.past24h.r) |
| 143 | Rectangle().fill(ink.opacity(0.25)).frame(width: 0.5) | 147 | Rectangle().fill(ink.opacity(0.25)).frame(width: stacked ? nil : 0.5, height: stacked ? 0.5 : nil) |
| 144 | scale("S", "Radiation", s.current.s, s.past24h.s) | 148 | scale("S", "Radiation", s.current.s, s.past24h.s) |
| 145 | Rectangle().fill(ink.opacity(0.25)).frame(width: 0.5) | 149 | Rectangle().fill(ink.opacity(0.25)).frame(width: stacked ? nil : 0.5, height: stacked ? 0.5 : nil) |
| 146 | scale("G", "Geomagnetic", s.current.g, s.past24h.g) | 150 | scale("G", "Geomagnetic", s.current.g, s.past24h.g) |
| 147 | } | 151 | } |
| 148 | .fixedSize(horizontal: false, vertical: true) | 152 | .fixedSize(horizontal: false, vertical: true) |
| @@ -150,11 +154,11 @@ struct SunScreen: View { | |||
| 150 | 154 | ||
| 151 | private func scale(_ letter: String, _ title: String, _ level: Int, _ past: Int) -> some View { | 155 | private func scale(_ letter: String, _ title: String, _ level: Int, _ past: Int) -> some View { |
| 152 | VStack(alignment: .leading, spacing: 2) { | 156 | VStack(alignment: .leading, spacing: 2) { |
| 153 | Text(title).font(.caption2).dimmed(0.6) | 157 | Text(title).font(.caption).dimmed(0.6) |
| 154 | Text("\(letter)\(level)").font(.title.weight(.medium).monospacedDigit()) | 158 | Text("\(letter)\(level)").font(.title.weight(.medium).monospacedDigit()) |
| 155 | .foregroundStyle(level >= 1 ? accent : ink) | 159 | .foregroundStyle(level >= 1 ? accent : ink) |
| 156 | Text(NOAAScales.levelName(level)).font(.footnote) | 160 | Text(NOAAScales.levelName(level)).font(.footnote) |
| 157 | Text("24 h max \(letter)\(past)").font(.caption2).dimmed(0.6) | 161 | Text("24 h max \(letter)\(past)").font(.caption).dimmed(0.6) |
| 158 | } | 162 | } |
| 159 | .frame(maxWidth: .infinity, alignment: .leading) | 163 | .frame(maxWidth: .infinity, alignment: .leading) |
| 160 | .accessibilityElement(children: .combine) | 164 | .accessibilityElement(children: .combine) |
App/Widgets/EclipseLiveActivity.swift +82 −47
| @@ -8,44 +8,9 @@ struct EclipseLiveActivity: Widget { | |||
| 8 | 8 | ||
| 9 | var body: some WidgetConfiguration { | 9 | var body: some WidgetConfiguration { |
| 10 | ActivityConfiguration(for: EclipseActivityAttributes.self) { context in | 10 | ActivityConfiguration(for: EclipseActivityAttributes.self) { context in |
| 11 | let a = context.attributes | 11 | EclipseActivityView(attributes: context.attributes, ink: ink, accent: accent) |
| 12 | VStack(alignment: .leading, spacing: 6) { | 12 | .foregroundStyle(ink) |
| 13 | Text("\(a.kind) eclipse · \(a.site)").font(.system(size: 15, weight: .medium)) | 13 | .activityBackgroundTint(.black) |
| 14 | HStack(alignment: .firstTextBaseline) { | ||
| 15 | if let c2 = a.c2, let c3 = a.c3 { | ||
| 16 | VStack(alignment: .leading, spacing: 0) { | ||
| 17 | Text(a.kind == "Total" ? "Totality in" : "Annularity in").font(.system(size: 12)).opacity(0.7) | ||
| 18 | countdown(to: c2).font(.system(size: 34, weight: .medium).monospacedDigit()) | ||
| 19 | } | ||
| 20 | Spacer() | ||
| 21 | VStack(alignment: .trailing, spacing: 0) { | ||
| 22 | Text("Lasts").font(.system(size: 12)).opacity(0.7) | ||
| 23 | Text(timerInterval: c2...c3, countsDown: true).font(.system(size: 34, weight: .medium).monospacedDigit()) | ||
| 24 | .foregroundStyle(accent).multilineTextAlignment(.trailing) | ||
| 25 | } | ||
| 26 | } else { | ||
| 27 | VStack(alignment: .leading, spacing: 0) { | ||
| 28 | Text("Maximum in").font(.system(size: 12)).opacity(0.7) | ||
| 29 | countdown(to: a.peak).font(.system(size: 34, weight: .medium).monospacedDigit()) | ||
| 30 | } | ||
| 31 | } | ||
| 32 | } | ||
| 33 | ProgressView(timerInterval: a.c1...a.c4, countsDown: false, label: { EmptyView() }, currentValueLabel: { EmptyView() }) | ||
| 34 | .tint(ink) | ||
| 35 | HStack { | ||
| 36 | ForEach(contacts(a), id: \.0) { name, date in | ||
| 37 | VStack(alignment: .leading, spacing: 0) { | ||
| 38 | Text(name).font(.system(size: 10)).opacity(0.6) | ||
| 39 | Text(clock(date, a)).font(.system(size: 11).monospacedDigit()) | ||
| 40 | } | ||
| 41 | if name != "C4" { Spacer(minLength: 4) } | ||
| 42 | } | ||
| 43 | } | ||
| 44 | Text(a.timeZone == nil ? "Times in UTC" : "Local time at the site").font(.system(size: 10)).opacity(0.6) | ||
| 45 | } | ||
| 46 | .padding(16) | ||
| 47 | .foregroundStyle(ink) | ||
| 48 | .activityBackgroundTint(.black) | ||
| 49 | } dynamicIsland: { context in | 14 | } dynamicIsland: { context in |
| 50 | let a = context.attributes | 15 | let a = context.attributes |
| 51 | let next = a.c2 ?? a.peak | 16 | let next = a.c2 ?? a.peak |
| @@ -69,26 +34,96 @@ struct EclipseLiveActivity: Widget { | |||
| 69 | } | 34 | } |
| 70 | } | 35 | } |
| 71 | } compactLeading: { | 36 | } compactLeading: { |
| 72 | Text(a.c2 == nil ? "Max" : "C2").font(.system(size: 12, weight: .medium)) | 37 | IslandWidth { |
| 38 | Text(a.c2 == nil ? "Max" : "C2").font(.system(size: 12, weight: .medium)) | ||
| 39 | } limited: { | ||
| 40 | Image(systemName: "sun.max") | ||
| 41 | } | ||
| 73 | } compactTrailing: { | 42 | } compactTrailing: { |
| 74 | countdown(to: next).font(.system(size: 12).monospacedDigit()).frame(maxWidth: 64) | 43 | countdown(to: next).font(.system(size: 12).monospacedDigit()).frame(maxWidth: 64) |
| 75 | } minimal: { | 44 | } minimal: { |
| 76 | Image(systemName: "sun.max") | 45 | Image(systemName: "sun.max") |
| 77 | } | 46 | } |
| 78 | } | 47 | } |
| 48 | .supplementalActivityFamilies([.small]) | ||
| 79 | } | 49 | } |
| 50 | } | ||
| 51 | |||
| 52 | private struct EclipseActivityView: View { | ||
| 53 | @Environment(\.activityFamily) private var family | ||
| 54 | var attributes: EclipseActivityAttributes | ||
| 55 | var ink: Color | ||
| 56 | var accent: Color | ||
| 80 | 57 | ||
| 81 | /// Counts down to `date` and then stays at zero. | 58 | private var a: EclipseActivityAttributes { attributes } |
| 82 | private func countdown(to date: Date) -> Text { | 59 | |
| 83 | Text(timerInterval: date.addingTimeInterval(-86_400)...date, countsDown: true) | 60 | var body: some View { |
| 61 | if family == .small { small } else { medium } | ||
| 84 | } | 62 | } |
| 85 | 63 | ||
| 86 | private func contacts(_ a: EclipseActivityAttributes) -> [(String, Date)] { | 64 | /// Apple Watch Smart Stack and CarPlay. |
| 87 | [("C1", a.c1), ("C2", a.c2), ("Max", a.c2 == nil ? a.peak : nil), ("C3", a.c3), ("C4", a.c4)].compactMap { n, d in d.map { (n, $0) } } | 65 | private var small: some View { |
| 66 | VStack(alignment: .leading, spacing: 2) { | ||
| 67 | Text("\(a.kind) eclipse").font(.system(size: 13, weight: .medium)).lineLimit(1) | ||
| 68 | HStack(spacing: 4) { | ||
| 69 | Text(a.c2 == nil ? "Max" : "C2") | ||
| 70 | countdown(to: a.c2 ?? a.peak) | ||
| 71 | } | ||
| 72 | .font(.system(size: 17, weight: .medium).monospacedDigit()) | ||
| 73 | } | ||
| 74 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 75 | .padding(12) | ||
| 88 | } | 76 | } |
| 89 | 77 | ||
| 90 | /// A string: Text(_:format:) would replace the style's time zone with the environment's. | 78 | private var medium: some View { |
| 91 | private func clock(_ date: Date, _ a: EclipseActivityAttributes) -> String { | 79 | VStack(alignment: .leading, spacing: 6) { |
| 92 | date.formatted(Date.FormatStyle(date: .omitted, time: .standard, timeZone: a.timeZone.flatMap(TimeZone.init(identifier:)) ?? .gmt)) | 80 | Text("\(a.kind) eclipse · \(a.site)").font(.system(size: 15, weight: .medium)) |
| 81 | HStack(alignment: .firstTextBaseline) { | ||
| 82 | if let c2 = a.c2, let c3 = a.c3 { | ||
| 83 | VStack(alignment: .leading, spacing: 0) { | ||
| 84 | Text(a.kind == "Total" ? "Totality in" : "Annularity in").font(.system(size: 12)).opacity(0.7) | ||
| 85 | countdown(to: c2).font(.system(size: 34, weight: .medium).monospacedDigit()) | ||
| 86 | } | ||
| 87 | Spacer() | ||
| 88 | VStack(alignment: .trailing, spacing: 0) { | ||
| 89 | Text("Lasts").font(.system(size: 12)).opacity(0.7) | ||
| 90 | Text(timerInterval: c2...c3, countsDown: true).font(.system(size: 34, weight: .medium).monospacedDigit()) | ||
| 91 | .foregroundStyle(accent).multilineTextAlignment(.trailing) | ||
| 92 | } | ||
| 93 | } else { | ||
| 94 | VStack(alignment: .leading, spacing: 0) { | ||
| 95 | Text("Maximum in").font(.system(size: 12)).opacity(0.7) | ||
| 96 | countdown(to: a.peak).font(.system(size: 34, weight: .medium).monospacedDigit()) | ||
| 97 | } | ||
| 98 | } | ||
| 99 | } | ||
| 100 | ProgressView(timerInterval: a.c1...a.c4, countsDown: false, label: { EmptyView() }, currentValueLabel: { EmptyView() }) | ||
| 101 | .tint(ink) | ||
| 102 | HStack { | ||
| 103 | ForEach(contacts(a), id: \.0) { name, date in | ||
| 104 | VStack(alignment: .leading, spacing: 0) { | ||
| 105 | Text(name).font(.system(size: 10)).opacity(0.6) | ||
| 106 | Text(clock(date, a)).font(.system(size: 11).monospacedDigit()) | ||
| 107 | } | ||
| 108 | if name != "C4" { Spacer(minLength: 4) } | ||
| 109 | } | ||
| 110 | } | ||
| 111 | Text(a.timeZone == nil ? "Times in UTC" : "Local time at the site").font(.system(size: 10)).opacity(0.6) | ||
| 112 | } | ||
| 113 | .padding(16) | ||
| 93 | } | 114 | } |
| 94 | } | 115 | } |
| 116 | |||
| 117 | /// Counts down to `date` and then stays at zero. | ||
| 118 | private func countdown(to date: Date) -> Text { | ||
| 119 | Text(timerInterval: date.addingTimeInterval(-86_400)...date, countsDown: true) | ||
| 120 | } | ||
| 121 | |||
| 122 | private func contacts(_ a: EclipseActivityAttributes) -> [(String, Date)] { | ||
| 123 | [("C1", a.c1), ("C2", a.c2), ("Max", a.c2 == nil ? a.peak : nil), ("C3", a.c3), ("C4", a.c4)].compactMap { n, d in d.map { (n, $0) } } | ||
| 124 | } | ||
| 125 | |||
| 126 | /// A string: Text(_:format:) would replace the style's time zone with the environment's. | ||
| 127 | private func clock(_ date: Date, _ a: EclipseActivityAttributes) -> String { | ||
| 128 | date.formatted(Date.FormatStyle(date: .omitted, time: .standard, timeZone: a.timeZone.flatMap(TimeZone.init(identifier:)) ?? .gmt)) | ||
| 129 | } | ||
Packages/MoonriseKit/Sources/Feeds/SunSummary.swift +3 −13
| @@ -1,20 +1,10 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import SkyEngine | ||
| 2 | 3 | ||
| 3 | /// One or two plain sentences on what the Sun is doing to Earth now, from NOAA's feeds. | 4 | /// One or two plain sentences on what the Sun is doing to Earth now, from NOAA's feeds. |
| 4 | /// The first matching rule wins: a geomagnetic storm, then a radiation storm, then a flare, then quiet. | 5 | /// The first matching rule wins: a geomagnetic storm, then a radiation storm, then a flare, then quiet. |
| 5 | /// Every sentence names a figure from a feed and its time. | 6 | /// Every sentence names a figure from a feed and its time. |
| 6 | public enum SunSummary { | 7 | public enum SunSummary { |
| 7 | /// Geomagnetic latitude of the auroral oval's equatorward edge for a Kp value. | ||
| 8 | /// Must match `ovalEdge` and `visibleFrom` in server/src/aurora.ts. | ||
| 9 | static func visibleFrom(kp: Double) -> Double { | ||
| 10 | 66.5 - 2.04 * kp - 6 | ||
| 11 | } | ||
| 12 | |||
| 13 | /// Lowest Kp at which aurora may be seen low on the horizon from this geomagnetic latitude. | ||
| 14 | static func kpNeeded(at geomagneticLatitude: Double) -> Double { | ||
| 15 | (66.5 - 6 - abs(geomagneticLatitude)) / 2.04 | ||
| 16 | } | ||
| 17 | |||
| 18 | /// `nil` when none of the feeds has loaded. `night` is the current or next night at the observer. | 8 | /// `nil` when none of the feeds has loaded. `night` is the current or next night at the observer. |
| 19 | public static func text( | 9 | public static func text( |
| 20 | scales: NOAAScales?, kp: [KpBar], probabilities: SolarProbabilities?, xray: [XRaySample], | 10 | scales: NOAAScales?, kp: [KpBar], probabilities: SolarProbabilities?, xray: [XRaySample], |
| @@ -32,7 +22,7 @@ public enum SunSummary { | |||
| 32 | let bar: TimeInterval = 3 * 3600 | 22 | let bar: TimeInterval = 3 * 3600 |
| 33 | let storms = kp.filter { $0.kp >= 5 && $0.start.addingTimeInterval(bar) > now && $0.start < now.addingTimeInterval(86_400) } | 23 | let storms = kp.filter { $0.kp >= 5 && $0.start.addingTimeInterval(bar) > now && $0.start < now.addingTimeInterval(86_400) } |
| 34 | let visible = storms.filter { | 24 | let visible = storms.filter { |
| 35 | visibleFrom(kp: $0.kp) <= abs(geomagneticLatitude) | 25 | Geomagnetic.visibleFrom(kp: $0.kp) <= abs(geomagneticLatitude) |
| 36 | && overlaps($0.start, bar, night) | 26 | && overlaps($0.start, bar, night) |
| 37 | } | 27 | } |
| 38 | let sky = geomagneticLatitude >= 0 ? "northern" : "southern" | 28 | let sky = geomagneticLatitude >= 0 ? "northern" : "southern" |
| @@ -44,7 +34,7 @@ public enum SunSummary { | |||
| 44 | return "\(name(g)) storm (G\(g)) \(when), Kp \(String(format: "%.1f", b.kp)). Aurora possible low in the \(sky) sky." | 34 | return "\(name(g)) storm (G\(g)) \(when), Kp \(String(format: "%.1f", b.kp)). Aurora possible low in the \(sky) sky." |
| 45 | } | 35 | } |
| 46 | if current.g >= 1 { | 36 | if current.g >= 1 { |
| 47 | let needed = kpNeeded(at: geomagneticLatitude) | 37 | let needed = Geomagnetic.kpNeeded(geomagneticLatitude: geomagneticLatitude) |
| 48 | var text = "\(name(current.g)) storm (G\(current.g))\(asOf)." | 38 | var text = "\(name(current.g)) storm (G\(current.g))\(asOf)." |
| 49 | if needed > 9 { | 39 | if needed > 9 { |
| 50 | text += " Aurora at your latitude would need more than Kp 9." | 40 | text += " Aurora at your latitude would need more than Kp 9." |
Packages/MoonriseKit/Sources/SkyEngine/Geomagnetic.swift +6 −1
| @@ -36,6 +36,11 @@ public enum Geomagnetic { | |||
| 36 | 36 | ||
| 37 | /// Kp at which aurora may be seen on the horizon from here; 0 under or poleward of the quiet oval. | 37 | /// Kp at which aurora may be seen on the horizon from here; 0 under or poleward of the quiet oval. |
| 38 | public static func kpNeeded(_ observer: Observer) -> Double { | 38 | public static func kpNeeded(_ observer: Observer) -> Double { |
| 39 | max(0, (visibleFrom(kp: 0) - abs(latitude(observer))) / 2.04) | 39 | kpNeeded(geomagneticLatitude: latitude(observer)) |
| 40 | } | ||
| 41 | |||
| 42 | /// `kpNeeded(_:)` for a geomagnetic latitude in degrees. | ||
| 43 | public static func kpNeeded(geomagneticLatitude: Double) -> Double { | ||
| 44 | max(0, (visibleFrom(kp: 0) - abs(geomagneticLatitude)) / 2.04) | ||
| 40 | } | 45 | } |
| 41 | } | 46 | } |
Packages/MoonriseKit/Tests/FeedsTests/SunSummaryTests.swift −5
| @@ -119,8 +119,3 @@ private func summary( | |||
| 119 | #expect(summary(scales: scales(r: 2, s: 1), xray: xray)?.hasPrefix("Minor radiation storm (S1)") == true) | 119 | #expect(summary(scales: scales(r: 2, s: 1), xray: xray)?.hasPrefix("Minor radiation storm (S1)") == true) |
| 120 | #expect(summary(scales: scales(r: 2), xray: xray)?.hasPrefix("Moderate flare (M5.0)") == true) | 120 | #expect(summary(scales: scales(r: 2), xray: xray)?.hasPrefix("Moderate flare (M5.0)") == true) |
| 121 | } | 121 | } |
| 122 | |||
| 123 | @Test func visibleLatitudeMatchesServer() { | ||
| 124 | // server/src/aurora.ts: visibleFrom(kp) = 66.5 - 2.04 * kp - 6 | ||
| 125 | #expect(abs(SunSummary.visibleFrom(kp: 5) - 50.3) < 1e-9) | ||
| 126 | } | ||