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

Commit ddd82475d9

ddd82475d91434d791c7eec7b20080f657377313

parent: 6fda452e77

Unsigned

cmc <hello@cleberg.net> · 2026-10-20 01:59 UTC

Satellite pass Live Activity; small, limited-width and StandBy layouts (!59)

## Changes

- **Pass Live Activity** (`App/Widgets/PassLiveActivity.swift`, `App/Shared/PassActivity.swift`). A bell on each featured pass row in the Today sheet starts it. Everything is computed on the device and there are no updates. The Lock Screen shows rise, highest and set times, rise and set directions, max altitude and a VISIBLE tag. A countdown to rise sits in the header, with a rise→set progress bar and the time left in the pass. If rise is more than 10 minutes away, the activity is scheduled with `request(…alertConfiguration:start:)` for rise −10 min, and the alert works as the reminder. Otherwise it starts now. The stale date is set +60 s, and the stale view shows "Passed HH:MM". The bell only shows for passes that rise within 24 h and haven't set yet.
- **Launch and event activities.** Added `.supplementalActivityFamilies([.small])` with two-line small layouts (object on top, timer below) for the Watch Smart Stack and CarPlay. The compact trailing timers now use `Text(.currentDate, format: .timer/.stopwatch)`, which sizes to its digits, in place of `Text(timerInterval:)` with `.frame(maxWidth: 64)`. The `isDynamicIslandLimitedInWidth` branches are gated to iOS 27 (`IslandWidth` in `App/Widgets/ActivityLayout.swift`):
  - Launch: leading `T−` becomes an arrow glyph, red while on hold, and trailing shows `HOLD` while held.
  - Event: past day 1, trailing shows mission time in days.
  - Pass: leading name becomes a glyph.
- **StandBy.** When `isActivityFullscreen` is true, the background tint is black instead of navy.
- **SkyEngine.** Added `SatellitePass.activityStart(now:)` and `SkyEngine.compass(_:)`, with tests.
- Widget bundle registration: one line.

## Not verified

- No layout has been checked on a device or in a simulator: Lock Screen, Dynamic Island (compact, minimal, expanded), limited width, the small family on Watch or CarPlay, and StandBy. The simulator host did not respond during this work.
- Not checked whether `Text(.currentDate, format:)` timers keep ticking inside a Live Activity.
- Not checked whether a scheduled (pending) activity is listed in `Activity.activities`, which the bell's on/off state depends on.
- Not checked whether the stale view shows after set with no update.
- `swift test`, `npm test` and the simulator build of the Moonrise scheme pass.

Layout: unified · split

App/Shared/PassActivity.swift added +17
@@ -0,0 +1,17 @@
1import ActivityKit
2import Foundation
3
4/// Live Activity for a satellite pass. Computed on the device and never updated: the view runs on timers
5/// and goes stale after set. Shared by the app and the widget extension.
6struct PassActivityAttributes: ActivityAttributes {
7 struct ContentState: Codable, Hashable {}
8
9 var name: String
10 var rise: Date
11 var peak: Date
12 var set: Date
13 var riseAzimuth: Double
14 var setAzimuth: Double
15 var maxAltitude: Double
16 var visible: Bool
17}
App/Sources/PassFollowButton.swift added +71
@@ -0,0 +1,71 @@
1import ActivityKit
2import SkyEngine
3import SwiftUI
4
5/// Bell on a pass row: puts the pass on the Lock Screen from 10 minutes before rise. Hidden for passes more
6/// than a day away.
7struct PassFollowButton: View {
8 var pass: SatellitePass
9 @State private var following = false
10 @State private var message: String?
11
12 var body: some View {
13 if pass.activityStart(now: .now) != nil {
14 Button {
15 following ? unfollow() : follow()
16 } label: {
17 Image(systemName: following ? "bell.fill" : "bell")
18 .font(.subheadline)
19 .frame(minWidth: 44, minHeight: 44)
20 .contentShape(Rectangle())
21 }
22 .buttonStyle(.plain)
23 .accessibilityLabel(following ? "Stop following on Lock Screen" : "Follow on Lock Screen")
24 .onAppear { following = Activity<PassActivityAttributes>.activities.contains { matches($0.attributes, pass) } }
25 .alert(message ?? "", isPresented: Binding { message != nil } set: { if !$0 { message = nil } }) {}
26 }
27 }
28
29 private func follow() {
30 guard ActivityAuthorizationInfo().areActivitiesEnabled else {
31 message = "Live Activities are turned off for Moonrise in Settings."
32 return
33 }
34 guard let start = pass.activityStart(now: .now) else { return }
35 let attributes = PassActivityAttributes(name: pass.displayName, rise: pass.rise, peak: pass.peak, set: pass.set,
36 riseAzimuth: pass.riseAzimuth, setAzimuth: pass.setAzimuth,
37 maxAltitude: pass.maxAltitude, visible: pass.visible)
38 // It cannot end itself without an update, so it goes stale a minute after set.
39 let content = ActivityContent(state: PassActivityAttributes.ContentState(), staleDate: pass.set.addingTimeInterval(60))
40 do {
41 if start > .now {
42 let rise = pass.rise.formatted(date: .omitted, time: .shortened)
43 let alert = AlertConfiguration(
44 title: "\(pass.displayName) pass in 10 minutes",
45 body: "Rises \(SkyEngine.compass(pass.riseAzimuth)) at \(rise), highest \(Int(pass.maxAltitude.rounded()))°, sets \(SkyEngine.compass(pass.setAzimuth)).",
46 sound: .default)
47 _ = try Activity.request(attributes: attributes, content: content, style: .standard, alertConfiguration: alert, start: start)
48 } else {
49 _ = try Activity.request(attributes: attributes, content: content)
50 }
51 following = true
52 } catch {
53 message = "Couldn't add the pass: \(error.localizedDescription)"
54 }
55 }
56
57 private func unfollow() {
58 let pass = pass
59 Task {
60 for a in Activity<PassActivityAttributes>.activities where matches(a.attributes, pass) {
61 await a.end(nil, dismissalPolicy: .immediate)
62 }
63 following = false
64 }
65 }
66}
67
68/// Rise times shift by a 20 s step each time passes are recomputed.
69private func matches(_ attributes: PassActivityAttributes, _ pass: SatellitePass) -> Bool {
70 attributes.name == pass.displayName && abs(attributes.rise.timeIntervalSince(pass.rise)) < 120
71}
App/Sources/TodaySheet.swift +3
@@ -99,6 +99,9 @@ struct TodaySheet: View {
9999 .font(.footnote.monospacedDigit()).dimmed(0.8)
100100 }
101101 .accessibilityElement(children: .combine)
102 .frame(maxWidth: .infinity, alignment: .leading)
103 .padding(.trailing, 44)
104 .overlay(alignment: .trailing) { PassFollowButton(pass: p) }
102105 }
103106 let others = alsoVisible
104107 if !others.isEmpty {
App/Widgets/ActivityLayout.swift added +36
@@ -0,0 +1,36 @@
1import SwiftUI
2import WidgetKit
3
4/// `full` in a normal Dynamic Island, `limited` where it is narrow: landscape, iPhone Duo, or several
5/// activities at once (iOS 27).
6struct IslandWidth<Full: View, Limited: View>: View {
7 @ViewBuilder var full: Full
8 @ViewBuilder var limited: Limited
9
10 var body: some View {
11 if #available(iOS 27, *) {
12 LimitedWidthReader(full: full, limited: limited)
13 } else {
14 full
15 }
16 }
17}
18
19@available(iOS 27, *)
20private struct LimitedWidthReader<Full: View, Limited: View>: View {
21 @Environment(\.isDynamicIslandLimitedInWidth) private var isLimited
22 var full: Full
23 var limited: Limited
24
25 var body: some View {
26 if isLimited { limited } else { full }
27 }
28}
29
30/// Counts down to `date`, or up from it once passed. Unlike `Text(timerInterval:)` it takes the width of its
31/// digits, not the widest the timer could get.
32func liveTimer(_ date: Date) -> Text {
33 date > .now
34 ? Text(.currentDate, format: .timer(countingDownIn: Date.now..<date))
35 : Text(.currentDate, format: .stopwatch(startingAt: date, maxPrecision: .seconds(1)))
36}
App/Widgets/EventLiveActivity.swift +57 −20
@@ -7,25 +7,8 @@ struct EventLiveActivity: Widget {
77
88 var body: some WidgetConfiguration {
99 ActivityConfiguration(for: EventActivityAttributes.self) { context in
10 VStack(alignment: .leading, spacing: 4) {
11 HStack {
12 Text(context.attributes.title).font(.system(size: 17, weight: .medium))
13 Spacer()
14 Text(context.attributes.launch, style: .timer).font(.system(size: 17, weight: .medium).monospacedDigit())
15 .multilineTextAlignment(.trailing)
16 }
17 Text(context.state.phase).font(.system(size: 14))
18 HStack {
19 Text("\(Int(context.state.distanceKm).formatted()) km from Earth").font(.system(size: 12)).opacity(0.8)
20 Spacer()
21 if let next = context.state.next, let at = context.state.nextAt {
22 Text("\(next) \(at, style: .relative)").font(.system(size: 12)).opacity(0.8).lineLimit(1)
23 }
24 }
25 }
26 .padding(16)
27 .foregroundStyle(ink)
28 .activityBackgroundTint(Color(red: 0.043, green: 0.078, blue: 0.141))
10 EventActivityView(context: context)
11 .foregroundStyle(ink)
2912 } dynamicIsland: { context in
3013 DynamicIsland {
3114 DynamicIslandExpandedRegion(.leading) {
@@ -40,10 +23,64 @@ struct EventLiveActivity: Widget {
4023 } compactLeading: {
4124 Image(systemName: "moon.stars")
4225 } compactTrailing: {
43 Text(context.attributes.launch, style: .timer).font(.system(size: 12).monospacedDigit()).frame(maxWidth: 64)
26 IslandWidth {
27 liveTimer(context.attributes.launch).font(.system(size: 12).monospacedDigit())
28 } limited: {
29 // Mission time in days once past the first day; the full timer does not fit.
30 if context.attributes.launch < .now.addingTimeInterval(-86_400) {
31 Text("\(Int(-context.attributes.launch.timeIntervalSinceNow / 86_400)) d").font(.system(size: 12).monospacedDigit())
32 } else {
33 liveTimer(context.attributes.launch).font(.system(size: 12).monospacedDigit())
34 }
35 }
4436 } minimal: {
4537 Image(systemName: "moon.stars")
4638 }
4739 }
40 .supplementalActivityFamilies([.small])
41 }
42}
43
44private struct EventActivityView: View {
45 @Environment(\.activityFamily) private var family
46 @Environment(\.isActivityFullscreen) private var fullscreen
47 var context: ActivityViewContext<EventActivityAttributes>
48
49 var body: some View {
50 Group {
51 if family == .small { small } else { medium }
52 }
53 // StandBy: black, to blend with the clock face.
54 .activityBackgroundTint(fullscreen ? .black : Color(red: 0.043, green: 0.078, blue: 0.141))
55 }
56
57 /// Apple Watch Smart Stack and CarPlay.
58 private var small: some View {
59 VStack(alignment: .leading, spacing: 2) {
60 Text(context.attributes.title).font(.system(size: 13, weight: .medium)).lineLimit(1)
61 liveTimer(context.attributes.launch).font(.system(size: 17, weight: .medium).monospacedDigit())
62 }
63 .frame(maxWidth: .infinity, alignment: .leading)
64 .padding(12)
65 }
66
67 private var medium: some View {
68 VStack(alignment: .leading, spacing: 4) {
69 HStack {
70 Text(context.attributes.title).font(.system(size: 17, weight: .medium))
71 Spacer()
72 Text(context.attributes.launch, style: .timer).font(.system(size: 17, weight: .medium).monospacedDigit())
73 .multilineTextAlignment(.trailing)
74 }
75 Text(context.state.phase).font(.system(size: 14))
76 HStack {
77 Text("\(Int(context.state.distanceKm).formatted()) km from Earth").font(.system(size: 12)).opacity(0.8)
78 Spacer()
79 if let next = context.state.next, let at = context.state.nextAt {
80 Text("\(next) \(at, style: .relative)").font(.system(size: 12)).opacity(0.8).lineLimit(1)
81 }
82 }
83 }
84 .padding(16)
4885 }
4986}
App/Widgets/LaunchLiveActivity.swift +72 −25
@@ -8,26 +8,8 @@ struct LaunchLiveActivity: Widget {
88
99 var body: some WidgetConfiguration {
1010 ActivityConfiguration(for: LaunchActivityAttributes.self) { context in
11 HStack(alignment: .center, spacing: 12) {
12 VStack(alignment: .leading, spacing: 2) {
13 Text(context.attributes.rocket).font(.system(size: 17, weight: .medium))
14 Text(context.attributes.mission).font(.system(size: 13)).lineLimit(1).opacity(0.8)
15 Text(context.attributes.location).font(.system(size: 11)).lineLimit(1).opacity(0.6)
16 }
17 Spacer()
18 VStack(alignment: .trailing, spacing: 2) {
19 Text(context.state.status.uppercased()).font(.system(size: 10, weight: .medium)).tracking(0.6)
20 .foregroundStyle(context.state.status == "Hold" ? accent : ink)
21 HStack(spacing: 2) {
22 Text(context.state.net > .now ? "T−" : "T+")
23 countdown(context.state.net)
24 }
25 .font(.system(size: 22, weight: .medium).monospacedDigit())
26 }
27 }
28 .padding(16)
29 .foregroundStyle(ink)
30 .activityBackgroundTint(Color(red: 0.043, green: 0.078, blue: 0.141))
11 LaunchActivityView(context: context, ink: ink, accent: accent)
12 .foregroundStyle(ink)
3113 } dynamicIsland: { context in
3214 DynamicIsland {
3315 DynamicIslandExpandedRegion(.leading) {
@@ -40,17 +22,82 @@ struct LaunchLiveActivity: Widget {
4022 Text(context.attributes.mission).font(.system(size: 12)).lineLimit(1).opacity(0.8)
4123 }
4224 } compactLeading: {
43 Text("T−").font(.system(size: 12, weight: .medium))
25 IslandWidth {
26 Text("T−").font(.system(size: 12, weight: .medium))
27 } limited: {
28 Image(systemName: "arrow.up").foregroundStyle(context.state.status == "Hold" ? accent : ink)
29 }
4430 } compactTrailing: {
45 countdown(context.state.net).font(.system(size: 12).monospacedDigit()).frame(maxWidth: 64)
31 IslandWidth {
32 liveTimer(context.state.net).font(.system(size: 12).monospacedDigit())
33 } limited: {
34 if context.state.status == "Hold" {
35 Text("HOLD").font(.system(size: 12, weight: .medium)).foregroundStyle(accent)
36 } else {
37 liveTimer(context.state.net).font(.system(size: 12).monospacedDigit())
38 }
39 }
4640 } minimal: {
4741 Image(systemName: "arrow.up")
4842 }
4943 }
44 .supplementalActivityFamilies([.small])
45 }
46}
47
48private struct LaunchActivityView: View {
49 @Environment(\.activityFamily) private var family
50 @Environment(\.isActivityFullscreen) private var fullscreen
51 var context: ActivityViewContext<LaunchActivityAttributes>
52 var ink: Color
53 var accent: Color
54
55 var body: some View {
56 Group {
57 if family == .small { small } else { medium }
58 }
59 // StandBy: black, to blend with the clock face.
60 .activityBackgroundTint(fullscreen ? .black : Color(red: 0.043, green: 0.078, blue: 0.141))
61 }
62
63 /// Apple Watch Smart Stack and CarPlay.
64 private var small: some View {
65 VStack(alignment: .leading, spacing: 2) {
66 Text(context.attributes.rocket).font(.system(size: 13, weight: .medium)).lineLimit(1)
67 HStack(spacing: 2) {
68 Text(context.state.net > .now ? "T−" : "T+")
69 liveTimer(context.state.net)
70 }
71 .font(.system(size: 17, weight: .medium).monospacedDigit())
72 .foregroundStyle(context.state.status == "Hold" ? accent : ink)
73 }
74 .frame(maxWidth: .infinity, alignment: .leading)
75 .padding(12)
5076 }
5177
52 /// Counts down to T-0, then shows elapsed time.
53 private func countdown(_ net: Date) -> Text {
54 net > .now ? Text(timerInterval: Date.now...net, countsDown: true) : Text(net, style: .timer)
78 private var medium: some View {
79 HStack(alignment: .center, spacing: 12) {
80 VStack(alignment: .leading, spacing: 2) {
81 Text(context.attributes.rocket).font(.system(size: 17, weight: .medium))
82 Text(context.attributes.mission).font(.system(size: 13)).lineLimit(1).opacity(0.8)
83 Text(context.attributes.location).font(.system(size: 11)).lineLimit(1).opacity(0.6)
84 }
85 Spacer()
86 VStack(alignment: .trailing, spacing: 2) {
87 Text(context.state.status.uppercased()).font(.system(size: 10, weight: .medium)).tracking(0.6)
88 .foregroundStyle(context.state.status == "Hold" ? accent : ink)
89 HStack(spacing: 2) {
90 Text(context.state.net > .now ? "T−" : "T+")
91 countdown(context.state.net)
92 }
93 .font(.system(size: 22, weight: .medium).monospacedDigit())
94 }
95 }
96 .padding(16)
5597 }
5698}
99
100/// Counts down to T-0, then shows elapsed time.
101private func countdown(_ net: Date) -> Text {
102 net > .now ? Text(timerInterval: Date.now...net, countsDown: true) : Text(net, style: .timer)
103}
App/Widgets/MoonriseWidgets.swift +1
@@ -13,6 +13,7 @@ struct MoonriseWidgets: WidgetBundle {
1313 LaunchLiveActivity()
1414 EventLiveActivity()
1515 EclipseLiveActivity()
16 PassLiveActivity()
1617 #endif
1718 }
1819}
App/Widgets/PassLiveActivity.swift added +128
@@ -0,0 +1,128 @@
1import ActivityKit
2import SkyEngine
3import SwiftUI
4import WidgetKit
5
6struct PassLiveActivity: Widget {
7 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
8
9 var body: some WidgetConfiguration {
10 ActivityConfiguration(for: PassActivityAttributes.self) { context in
11 PassActivityView(context: context, ink: ink)
12 .foregroundStyle(ink)
13 } dynamicIsland: { context in
14 DynamicIsland {
15 DynamicIslandExpandedRegion(.leading) {
16 Text(context.attributes.name).font(.system(size: 14, weight: .medium)).lineLimit(1)
17 }
18 DynamicIslandExpandedRegion(.trailing) {
19 liveTimer(context.attributes.rise).font(.system(size: 14, weight: .medium).monospacedDigit())
20 }
21 DynamicIslandExpandedRegion(.bottom) {
22 if context.isStale {
23 Text("Passed \(time(context.attributes.set))").font(.system(size: 12)).opacity(0.8)
24 } else {
25 VStack(alignment: .leading, spacing: 6) {
26 Text(summary(context.attributes)).font(.system(size: 12).monospacedDigit()).lineLimit(1).opacity(0.8)
27 passProgress(context.attributes, ink: ink)
28 }
29 }
30 }
31 } compactLeading: {
32 IslandWidth {
33 Text(context.attributes.name).font(.system(size: 12, weight: .medium)).lineLimit(1)
34 } limited: {
35 Image(systemName: "sparkle")
36 }
37 } compactTrailing: {
38 liveTimer(context.attributes.rise).font(.system(size: 12).monospacedDigit())
39 } minimal: {
40 Image(systemName: "sparkle")
41 }
42 }
43 .supplementalActivityFamilies([.small])
44 }
45}
46
47private struct PassActivityView: View {
48 @Environment(\.activityFamily) private var family
49 @Environment(\.isActivityFullscreen) private var fullscreen
50 var context: ActivityViewContext<PassActivityAttributes>
51 var ink: Color
52
53 private var pass: PassActivityAttributes { context.attributes }
54
55 var body: some View {
56 Group {
57 if family == .small { small } else { medium }
58 }
59 // StandBy: black, to blend with the clock face.
60 .activityBackgroundTint(fullscreen ? .black : Color(red: 0.043, green: 0.078, blue: 0.141))
61 }
62
63 /// Apple Watch Smart Stack and CarPlay.
64 private var small: some View {
65 VStack(alignment: .leading, spacing: 2) {
66 Text("\(pass.name) \(Int(pass.maxAltitude.rounded()))°").font(.system(size: 13, weight: .medium)).lineLimit(1)
67 if context.isStale {
68 Text("Passed \(time(pass.set))").font(.system(size: 13).monospacedDigit()).opacity(0.8)
69 } else {
70 liveTimer(pass.rise).font(.system(size: 17, weight: .medium).monospacedDigit())
71 }
72 }
73 .frame(maxWidth: .infinity, alignment: .leading)
74 .padding(12)
75 }
76
77 private var medium: some View {
78 VStack(alignment: .leading, spacing: 8) {
79 HStack(alignment: .firstTextBaseline) {
80 Text("\(pass.name) pass").font(.system(size: 10, weight: .medium)).textCase(.uppercase).tracking(0.6).opacity(0.8)
81 if pass.visible {
82 Text("Visible").font(.system(size: 10, weight: .medium)).textCase(.uppercase).tracking(0.6)
83 }
84 Spacer()
85 if !context.isStale {
86 liveTimer(pass.rise).font(.system(size: 17, weight: .medium).monospacedDigit())
87 }
88 }
89 Rectangle().fill(ink.opacity(0.4)).frame(height: 0.5)
90 if context.isStale {
91 Text("Passed \(time(pass.set))").font(.system(size: 17, weight: .medium).monospacedDigit())
92 } else {
93 HStack(alignment: .top) {
94 column("Rise", time(pass.rise), SkyEngine.compass(pass.riseAzimuth))
95 Spacer()
96 column("Highest", time(pass.peak), "\(Int(pass.maxAltitude.rounded()))°")
97 Spacer()
98 column("Set", time(pass.set), SkyEngine.compass(pass.setAzimuth))
99 }
100 passProgress(pass, ink: ink)
101 }
102 }
103 .padding(16)
104 }
105
106 private func column(_ label: String, _ value: String, _ detail: String) -> some View {
107 VStack(alignment: .leading, spacing: 1) {
108 Text(label).font(.system(size: 10, weight: .medium)).textCase(.uppercase).tracking(0.6).opacity(0.6)
109 Text("\(value) \(detail)").font(.system(size: 15, weight: .medium).monospacedDigit())
110 }
111 }
112}
113
114/// Empty before rise, full at set, with the time left in the pass beside it.
115private func passProgress(_ pass: PassActivityAttributes, ink: Color) -> some View {
116 HStack(spacing: 8) {
117 ProgressView(timerInterval: pass.rise...pass.set, countsDown: false) { EmptyView() } currentValueLabel: { EmptyView() }
118 .tint(ink)
119 Text(.currentDate, format: .timer(countingDownIn: pass.rise..<pass.set))
120 .font(.system(size: 12).monospacedDigit()).opacity(0.8)
121 }
122}
123
124private func summary(_ pass: PassActivityAttributes) -> String {
125 "Rises \(SkyEngine.compass(pass.riseAzimuth)) \(time(pass.rise)), highest \(Int(pass.maxAltitude.rounded()))°, sets \(SkyEngine.compass(pass.setAzimuth))"
126}
127
128private func time(_ date: Date) -> String { date.formatted(date: .omitted, time: .shortened) }
Packages/MoonriseKit/Sources/SkyEngine/Passes.swift +12
@@ -24,6 +24,13 @@ public struct SatellitePass: Sendable, Identifiable, Equatable {
2424 default: name.replacingOccurrences(of: " R/B", with: " rocket body")
2525 }
2626 }
27
28 /// When a Lock Screen activity for this pass starts: 10 minutes before rise, or `now` if that has passed.
29 /// Nil once the pass has set, or when rise is more than a day away and the elements will have drifted.
30 public func activityStart(now: Date) -> Date? {
31 guard set > now, rise.timeIntervalSince(now) <= 86_400 else { return nil }
32 return max(now, rise.addingTimeInterval(-600))
33 }
2734}
2835
2936extension OrbitalElements {
@@ -33,6 +40,11 @@ extension OrbitalElements {
3340}
3441
3542extension SkyEngine {
43 /// Eight-point compass direction for an azimuth in degrees.
44 public static func compass(_ azimuth: Double) -> String {
45 ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Int(((azimuth + 22.5) / 45).rounded(.down)) % 8]
46 }
47
3648 /// Greenwich mean sidereal time in radians (IAU 1982, as used with SGP4's TEME frame).
3749 static func gmst(_ date: Date) -> Double {
3850 let jd = date.timeIntervalSince1970 / 86_400 + 2_440_587.5
Packages/MoonriseKit/Tests/SkyEngineTests/PassActivityTests.swift added +33
@@ -0,0 +1,33 @@
1import Foundation
2import Testing
3@testable import SkyEngine
4
5private let now = Date(timeIntervalSince1970: 1_792_364_400)
6
7private func pass(risingIn seconds: TimeInterval) -> SatellitePass {
8 SatellitePass(name: "ISS (ZARYA)", rise: now.addingTimeInterval(seconds), set: now.addingTimeInterval(seconds + 360), peak: now.addingTimeInterval(seconds + 180),
9 maxAltitude: 62, riseAzimuth: 240, setAzimuth: 60, visible: true, featured: true)
10}
11
12@Test func activityStartsTenMinutesBeforeRise() {
13 #expect(pass(risingIn: 3600).activityStart(now: now) == now.addingTimeInterval(3000))
14 #expect(pass(risingIn: 24 * 3600).activityStart(now: now) == now.addingTimeInterval(24 * 3600 - 600))
15}
16
17@Test func activityStartsNowWithinTenMinutesOrDuringThePass() {
18 #expect(pass(risingIn: 300).activityStart(now: now) == now)
19 #expect(pass(risingIn: -120).activityStart(now: now) == now)
20}
21
22@Test func noActivityAfterSetOrBeyondADay() {
23 #expect(pass(risingIn: -360).activityStart(now: now) == nil)
24 #expect(pass(risingIn: 24 * 3600 + 1).activityStart(now: now) == nil)
25}
26
27@Test func compassPoints() {
28 #expect(SkyEngine.compass(0) == "N")
29 #expect(SkyEngine.compass(22.4) == "N")
30 #expect(SkyEngine.compass(22.5) == "NE")
31 #expect(SkyEngine.compass(240) == "SW")
32 #expect(SkyEngine.compass(359) == "N")
33}