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 d11c0e0ac5

d11c0e0ac5df1b393de01ae0308b9f7138efbcd7

parent: 37eb12fbc6

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-17 23:44 UTC

Add device-motion pointing and record M1 status

- SkyRenderer: Pointing.direction from the Core Motion rotation matrix
  (x true north, z vertical), unit-tested for four orientations.
- App: MotionPointer (60 Hz, low-pass, wrap-safe azimuth); header button
  to follow the phone; dragging stops following.
- PLAN.org: M1 status.

Layout: unified · split

App/Sources/MotionPointer.swift added +39
@@ -0,0 +1,39 @@
1import CoreMotion
2import Observation
3import SkyEngine
4import SkyRenderer
5
6/// Points the sky where the back of the phone points, using true north.
7@MainActor
8@Observable
9final class MotionPointer {
10 private(set) var active = false
11 private(set) var direction = Horizontal(altitude: 45, azimuth: 180)
12 var available: Bool { CMMotionManager.availableAttitudeReferenceFrames().contains(.xTrueNorthZVertical) }
13
14 private let manager = CMMotionManager()
15
16 func start() {
17 guard available, !active else { return }
18 active = true
19 manager.deviceMotionUpdateInterval = 1.0 / 60
20 manager.startDeviceMotionUpdates(using: .xTrueNorthZVertical, to: .main) { [weak self] motion, _ in
21 guard let self, let m = motion?.attitude.rotationMatrix else { return }
22 MainActor.assumeIsolated { self.update(Pointing.direction(m31: m.m31, m32: m.m32, m33: m.m33)) }
23 }
24 }
25
26 func stop() {
27 manager.stopDeviceMotionUpdates()
28 active = false
29 }
30
31 /// Light low-pass filter; azimuth is smoothed across the 0/360 wrap.
32 private func update(_ h: Horizontal) {
33 let k = 0.25
34 let dAz = remainder(h.azimuth - direction.azimuth, 360)
35 var az = direction.azimuth + k * dAz
36 if az < 0 { az += 360 } else if az >= 360 { az -= 360 }
37 direction = Horizontal(altitude: direction.altitude + k * (h.altitude - direction.altitude), azimuth: az)
38 }
39}
App/Sources/SkyScreen.swift +19
@@ -4,6 +4,7 @@ import SwiftUI
4 4
5struct SkyScreen: View { 5struct SkyScreen: View {
6 @State private var model = SkyModel() 6 @State private var model = SkyModel()
7 @State private var pointer = MotionPointer()
7 @State private var azimuth = 180.0 8 @State private var azimuth = 180.0
8 @State private var altitude = 45.0 9 @State private var altitude = 45.0
9 @State private var fieldOfView = 100.0 10 @State private var fieldOfView = 100.0
@@ -67,6 +68,7 @@ struct SkyScreen: View {
67 /// The view direction with any in-progress gestures applied. 68 /// The view direction with any in-progress gestures applied.
68 private func current(width: CGFloat) -> (azimuth: Double, altitude: Double, fieldOfView: Double) { 69 private func current(width: CGFloat) -> (azimuth: Double, altitude: Double, fieldOfView: Double) {
69 let fov = min(max(fieldOfView / Double(zoom), 30), 140) 70 let fov = min(max(fieldOfView / Double(zoom), 30), 140)
71 if pointer.active { return (pointer.direction.azimuth, pointer.direction.altitude, fov) }
70 let degreesPerPoint = fov / Double(max(width, 1)) 72 let degreesPerPoint = fov / Double(max(width, 1))
71 let az = (azimuth - Double(drag.width) * degreesPerPoint).truncatingRemainder(dividingBy: 360) 73 let az = (azimuth - Double(drag.width) * degreesPerPoint).truncatingRemainder(dividingBy: 360)
72 let alt = min(max(altitude + Double(drag.height) * degreesPerPoint, 0), 90) 74 let alt = min(max(altitude + Double(drag.height) * degreesPerPoint, 0), 90)
@@ -75,6 +77,13 @@ struct SkyScreen: View {
75 77
76 private func panGesture(width: CGFloat) -> some Gesture { 78 private func panGesture(width: CGFloat) -> some Gesture {
77 DragGesture(minimumDistance: 6) 79 DragGesture(minimumDistance: 6)
80 .onChanged { _ in
81 if pointer.active {
82 azimuth = pointer.direction.azimuth
83 altitude = min(max(pointer.direction.altitude, 0), 90)
84 pointer.stop()
85 }
86 }
78 .updating($drag) { value, state, _ in state = value.translation } 87 .updating($drag) { value, state, _ in state = value.translation }
79 .onEnded { value in 88 .onEnded { value in
80 let degreesPerPoint = fieldOfView / Double(max(width, 1)) 89 let degreesPerPoint = fieldOfView / Double(max(width, 1))
@@ -102,6 +111,16 @@ struct SkyScreen: View {
102 } 111 }
103 } 112 }
104 Spacer() 113 Spacer()
114 if pointer.available {
115 Button {
116 pointer.active ? pointer.stop() : pointer.start()
117 } label: {
118 Image(systemName: pointer.active ? "scope" : "hand.draw")
119 .font(.system(size: 17))
120 .frame(width: 44, height: 44)
121 }
122 .accessibilityLabel(pointer.active ? "Stop following the phone" : "Point the phone at the sky")
123 }
105 Button { 124 Button {
106 night.toggle() 125 night.toggle()
107 } label: { 126 } label: {
PLAN.org +19
@@ -310,6 +310,25 @@ The Library is the largest piece of work.
310 after the test. A real push still needs an APNs key and a device token. 310 after the test. A real push still needs an APNs key and a device token.
311- Name reserved in App Store Connect; APNs key created (key ID 644XS4F2RG). 311- Name reserved in App Store Connect; APNs key created (key ID 644XS4F2RG).
312 312
313** M1 status (2026-10-17)
314Stacked MRs: !2 app shell, !3 interaction, !4 Today sheet, !5 widgets,
315!6 device pointing.
316
317Done and checked in the iOS 27 simulator: live sky with location, drag
318and pinch, object cards, time scrubber, night mode, Today sheet (moon,
319planets, sun, live SWPC Kp), sandbox APNs token.
320
321Built but not checked:
322- Widgets: build and embed; not viewed (simulator widget gallery could
323 not be driven by automation).
324- Device pointing: maths unit-tested; needs a real phone.
325
326Not done:
327- Draw time on a real iPhone (decides Canvas vs Metal).
328- ISS passes: need an SGP4 propagator and CelesTrak data via the backend.
329- Launches and Voyager in the Today sheet: backend (M2, M3).
330- Automatic night mode after astronomical dusk.
331
313* roadmap 332* roadmap
314** v1: now 333** v1: now
315Sky, System, Sun, Launches, Library core (above). 334Sky, System, Sun, Launches, Library core (above).
Packages/MoonriseKit/Sources/SkyRenderer/Pointing.swift added +15
@@ -0,0 +1,15 @@
1import Foundation
2import SkyEngine
3
4public enum Pointing {
5 /// Where the back of the phone points, from the third row of a Core Motion
6 /// rotation matrix in the x-true-north, z-vertical reference frame
7 /// (x north, y west, z up). The camera looks along the device's −z axis.
8 public static func direction(m31: Double, m32: Double, m33: Double) -> Horizontal {
9 let north = -m31, west = -m32, up = -m33
10 let altitude = asin(max(-1, min(1, up))) * 180 / .pi
11 var azimuth = atan2(-west, north) * 180 / .pi
12 if azimuth < 0 { azimuth += 360 }
13 return Horizontal(altitude: altitude, azimuth: azimuth)
14 }
15}
Packages/MoonriseKit/Tests/SkyRendererTests/PointingTests.swift added +32
@@ -0,0 +1,32 @@
1import Testing
2import SkyEngine
3@testable import SkyRenderer
4
5// Rows of the rotation matrix are the device axes in reference coordinates
6// (x north, y west, z up); the third row is the device z axis, toward the user.
7
8@Test func uprightFacingNorth() {
9 // Screen faces south, so device z = south = (-1, 0, 0).
10 let h = Pointing.direction(m31: -1, m32: 0, m33: 0)
11 #expect(abs(h.altitude) < 1e-9)
12 #expect(abs(h.azimuth) < 1e-9)
13}
14
15@Test func uprightFacingEast() {
16 // Screen faces west: device z = (0, 1, 0).
17 let h = Pointing.direction(m31: 0, m32: 1, m33: 0)
18 #expect(abs(h.azimuth - 90) < 1e-9)
19}
20
21@Test func flatFaceUpPointsDown() {
22 let h = Pointing.direction(m31: 0, m32: 0, m33: 1)
23 #expect(abs(h.altitude + 90) < 1e-9)
24}
25
26@Test func tiltedUpToward45DegreesSouth() {
27 // Camera toward south and 45° up: −z = (−s, 0, s), so z = (s, 0, −s).
28 let s = 0.5.squareRoot()
29 let h = Pointing.direction(m31: s, m32: 0, m33: -s)
30 #expect(abs(h.altitude - 45) < 1e-9)
31 #expect(abs(h.azimuth - 180) < 1e-9)
32}