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