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

59d401dca1a327705632d1e2e20f00f807dee828
moonrise/App/Sources/TodaySheet.swift history · blame · raw

221 lines · 9540 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("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}