App/Sources/TodaySheet.swift
216 lines · 9085 bytes
1import Feeds
2import SkyEngine
3import SwiftUI
4
5/// Tonight's details: moon, planets, sun and space weather.
6struct TodaySheet: View {
7 @Bindable var model: SkyModel
8 var ink: Color
9 var background: Color
10
11 var body: some View {
12 ScrollView {
13 VStack(alignment: .leading, spacing: 0) {
14 if let t = model.tonight {
15 section("Moon") { moon(t) }
16 section("Planets tonight") { planets(t) }
17 section("Sun") { sun(t) }
18 }
19 section("Satellite passes") { satellites }
20 section("Space weather") { weather }
21 Text("Space weather data: NOAA Space Weather Prediction Center.")
22 .font(.caption2).dimmed(0.5).padding(.top, 8)
23 }
24 .padding(.horizontal, 20)
25 .padding(.top, 18)
26 }
27 .foregroundStyle(ink)
28 .presentationBackground(background)
29 .task { await model.refreshPasses() }
30 }
31
32 private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
33 VStack(alignment: .leading, spacing: 8) {
34 Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
35 content()
36 }
37 .frame(maxWidth: .infinity, alignment: .leading)
38 .padding(.vertical, 12)
39 .overlay(alignment: .top) { Rectangle().fill(ink.opacity(0.25)).frame(height: 0.5) }
40 }
41
42 private func moon(_ t: Tonight) -> some View {
43 let quarter = switch t.nextQuarter.quarter {
44 case .new: "New moon"
45 case .firstQuarter: "First quarter"
46 case .full: "Full moon"
47 case .lastQuarter: "Last quarter"
48 }
49 return rows([
50 ("Illuminated", "\(Int((t.moonIllumination * 100).rounded()))%, \(t.moonPhase < 180 ? "waxing" : "waning")"),
51 (quarter, t.nextQuarter.date.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())),
52 ])
53 }
54
55 private func planets(_ t: Tonight) -> some View {
56 rows(t.planets.map { p in
57 let name = p.body.rawValue.capitalized
58 guard let best = p.best else { return (name, "not well placed tonight") }
59 var parts: [String] = []
60 if p.now.altitude > 0 {
61 parts.append(String(format: "up now, %.0f° %@", p.now.altitude, compass(p.now.azimuth)))
62 } else if let rise = p.rise, rise < t.end {
63 parts.append("rises \(time(rise))")
64 }
65 // A best point at the end of the window means it is still climbing at dawn.
66 if t.end.timeIntervalSince(best.date) < 900 {
67 parts.append(String(format: "%.0f° %@ by dawn", best.position.altitude, compass(best.position.azimuth)))
68 } else {
69 parts.append(String(format: "highest %@ at %.0f°", time(best.date), best.position.altitude))
70 }
71 return (name, parts.joined(separator: " · "))
72 })
73 }
74
75 private func sun(_ t: Tonight) -> some View {
76 rows([
77 ("Sunset", t.sunset.map(time) ?? "already set"),
78 ("Sunrise", t.sunrise.map(time) ?? "–"),
79 ])
80 }
81
82 @ViewBuilder
83 private var satellites: some View {
84 let shown = model.passes.filter(\.featured).prefix(8)
85 if shown.isEmpty {
86 Text("No passes above 10° in the next three days.").font(.subheadline).dimmed(0.6)
87 } else {
88 VStack(alignment: .leading, spacing: 10) {
89 ForEach(Array(shown)) { p in
90 VStack(alignment: .leading, spacing: 2) {
91 HStack {
92 Text(p.displayName).font(.subheadline.weight(.medium))
93 if p.visible {
94 Text("Visible").font(.caption2.weight(.medium)).padding(.horizontal, 5).padding(.vertical, 1)
95 .overlay(RoundedRectangle(cornerRadius: 3).stroke(lineWidth: 1))
96 }
97 }
98 Text(p.rise.formatted(.dateTime.weekday(.abbreviated).hour().minute()) + String(format: " · rises %@, highest %.0f°, sets %@", compass(p.riseAzimuth), p.maxAltitude, compass(p.setAzimuth)))
99 .font(.footnote.monospacedDigit()).dimmed(0.8)
100 }
101 .accessibilityElement(children: .combine)
102 }
103 let others = alsoVisible
104 if !others.isEmpty {
105 Text("Also visible tonight").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6).padding(.top, 6)
106 ForEach(others) { p in
107 Text("\(p.displayName) · \(p.rise.formatted(date: .omitted, time: .shortened)), highest \(Int(p.maxAltitude.rounded()))°")
108 .font(.footnote.monospacedDigit()).dimmed(0.8)
109 }
110 }
111 Text("Visible means sunlit while your sky is dark. Orbits: CelesTrak.").font(.caption2).dimmed(0.6)
112 }
113 }
114 }
115
116 /// The five highest passes tonight of other bright satellites (rocket bodies, old satellites), in time order.
117 private var alsoVisible: [SatellitePass] {
118 guard let t = model.tonight else { return [] }
119 return Array(model.passes
120 .filter { !$0.featured && $0.visible && $0.rise >= t.start && $0.rise <= t.end && $0.maxAltitude >= 30 }
121 .sorted { $0.maxAltitude > $1.maxAltitude }
122 .prefix(5))
123 .sorted { $0.rise < $1.rise }
124 }
125
126 @ViewBuilder
127 private var weather: some View {
128 if let w = model.spaceWeather {
129 rows([
130 ("Kp now", String(format: "%.1f%@", w.kpNow, SpaceWeather.gScale(w.kpNow).map { " (G\($0) storm)" } ?? "")),
131 ("Next 24 h", w.kpNext24h.map { String(format: "up to Kp %.1f%@", $0, SpaceWeather.gScale($0).map { " (G\($0))" } ?? "") } ?? "–"),
132 ("Updated", time(w.observedAt)),
133 ])
134 } else {
135 Text("Loading").font(.footnote).dimmed(0.6)
136 }
137 }
138
139 private func rows(_ items: [(String, String)]) -> some View {
140 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
141 ForEach(items, id: \.0) { item in
142 GridRow {
143 Text(item.0).dimmed(0.7)
144 Text(item.1).monospacedDigit()
145 }
146 .font(.subheadline)
147 }
148 }
149 }
150
151 private func time(_ d: Date) -> String { d.formatted(date: .omitted, time: .shortened) }
152
153 private func compass(_ azimuth: Double) -> String {
154 ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Int(((azimuth + 22.5) / 45).rounded(.down)) % 8]
155 }
156}
157
158/// Bottom bar on the sky: time scrubber and a one-line summary that opens the Today sheet.
159struct TodayBar: View {
160 @Bindable var model: SkyModel
161 var ink: Color
162 var background: Color
163 var onExpand: () -> Void
164
165 var body: some View {
166 VStack(alignment: .leading, spacing: 6) {
167 scrubber
168 Button(action: onExpand) {
169 HStack {
170 Text(summary).font(.footnote).dimmed(0.8)
171 Spacer()
172 Image(systemName: "chevron.up").font(.caption.weight(.medium)).dimmed(0.6)
173 }
174 .frame(minHeight: 44)
175 .contentShape(Rectangle())
176 }
177 .buttonStyle(.plain)
178 .accessibilityHint("Shows tonight's details")
179 }
180 .padding(.horizontal, 20)
181 .padding(.vertical, 10)
182 .background(background.opacity(0.92))
183 .task {
184 while !Task.isCancelled {
185 await model.refreshSpaceWeather()
186 try? await Task.sleep(for: .seconds(600))
187 }
188 }
189 }
190
191 private var summary: String {
192 var parts: [String] = []
193 if let t = model.tonight {
194 parts.append("Moon \(Int((t.moonIllumination * 100).rounded()))%")
195 let up = t.planets.filter { $0.best != nil }.map { $0.body.rawValue.capitalized }
196 parts.append(up.isEmpty ? "No bright planets tonight" : up.joined(separator: ", "))
197 }
198 if let w = model.spaceWeather { parts.append(String(format: "Kp %.1f", w.kpNow)) }
199 return parts.joined(separator: " · ")
200 }
201
202 private var scrubber: some View {
203 HStack(spacing: 12) {
204 Text(model.displayedDate.formatted(date: .omitted, time: .shortened))
205 .font(.footnote.monospacedDigit())
206 .fixedSize()
207 Slider(value: $model.offsetHours, in: -12...12, step: 0.25).tint(ink)
208 Button { model.offsetHours = 0 } label: {
209 Text("Now").frame(minWidth: 44, minHeight: 44).contentShape(Rectangle())
210 }
211 .font(.footnote.weight(.medium))
212 .opacity(model.offsetHours == 0 ? 0.6 : 1)
213 }
214 }
215
216}