krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

App/Sources/TodaySheet.swift

main
moonrise/App/Sources/TodaySheet.swift history · blame · raw

216 lines · 9085 bytes

9 symbols in this file
  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}