Commit f9b8adc413
Verified · cmc
Layout: unified · split
.gitignore +1
| @@ -5,3 +5,4 @@ node_modules/ | |||
| 5 | .wrangler/ | 5 | .wrangler/ |
| 6 | .dev.vars | 6 | .dev.vars |
| 7 | *.p8 | 7 | *.p8 |
| 8 | App/*.xcodeproj/ | ||
App/README.org added +13
| @@ -0,0 +1,13 @@ | |||
| 1 | #+title: Moonrise app | ||
| 2 | |||
| 3 | The Xcode project is generated from =project.yml= and not committed. | ||
| 4 | |||
| 5 | #+begin_src sh | ||
| 6 | brew install xcodegen | ||
| 7 | cd App && xcodegen generate | ||
| 8 | open Moonrise.xcodeproj | ||
| 9 | #+end_src | ||
| 10 | |||
| 11 | Simulator: an iOS 26+ runtime is required. Push registration needs a | ||
| 12 | signed build (team =ZCNAX3VL9D=); the APNs device token is logged under | ||
| 13 | subsystem =sh.krz.moonrise=. | ||
App/Sources/Moonrise.entitlements added +8
| @@ -0,0 +1,8 @@ | |||
| 1 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| 3 | <plist version="1.0"> | ||
| 4 | <dict> | ||
| 5 | <key>aps-environment</key> | ||
| 6 | <string>development</string> | ||
| 7 | </dict> | ||
| 8 | </plist> | ||
App/Sources/MoonriseApp.swift added +33
| @@ -0,0 +1,33 @@ | |||
| 1 | import SwiftUI | ||
| 2 | import UIKit | ||
| 3 | import os | ||
| 4 | |||
| 5 | let log = Logger(subsystem: "sh.krz.moonrise", category: "app") | ||
| 6 | |||
| 7 | @main | ||
| 8 | struct MoonriseApp: App { | ||
| 9 | @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate | ||
| 10 | |||
| 11 | var body: some Scene { | ||
| 12 | WindowGroup { | ||
| 13 | SkyScreen() | ||
| 14 | .preferredColorScheme(.dark) | ||
| 15 | } | ||
| 16 | } | ||
| 17 | } | ||
| 18 | |||
| 19 | final class AppDelegate: NSObject, UIApplicationDelegate { | ||
| 20 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { | ||
| 21 | application.registerForRemoteNotifications() | ||
| 22 | return true | ||
| 23 | } | ||
| 24 | |||
| 25 | func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { | ||
| 26 | let token = deviceToken.map { String(format: "%02x", $0) }.joined() | ||
| 27 | log.info("APNs device token: \(token, privacy: .public)") | ||
| 28 | } | ||
| 29 | |||
| 30 | func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { | ||
| 31 | log.error("APNs registration failed: \(error.localizedDescription, privacy: .public)") | ||
| 32 | } | ||
| 33 | } | ||
App/Sources/SkyModel.swift added +70
| @@ -0,0 +1,70 @@ | |||
| 1 | import CoreLocation | ||
| 2 | import Foundation | ||
| 3 | import Observation | ||
| 4 | import SkyEngine | ||
| 5 | import SkyRenderer | ||
| 6 | |||
| 7 | /// Current place and the scene computed for it. Scenes are built off the main | ||
| 8 | /// thread one at a time, since Astronomy Engine is not thread-safe. | ||
| 9 | @MainActor | ||
| 10 | @Observable | ||
| 11 | final class SkyModel: NSObject, CLLocationManagerDelegate { | ||
| 12 | /// Used until a location fix arrives or when location is denied. | ||
| 13 | static let fallback = Observer(latitude: 41.8781, longitude: -87.6298) | ||
| 14 | |||
| 15 | private(set) var observer = SkyModel.fallback | ||
| 16 | private(set) var hasLocation = false | ||
| 17 | private(set) var scene: SkyScene? | ||
| 18 | private(set) var lastBuild: Duration = .zero | ||
| 19 | |||
| 20 | private let manager = CLLocationManager() | ||
| 21 | private var building = false | ||
| 22 | |||
| 23 | override init() { | ||
| 24 | super.init() | ||
| 25 | manager.delegate = self | ||
| 26 | manager.desiredAccuracy = kCLLocationAccuracyKilometer | ||
| 27 | } | ||
| 28 | |||
| 29 | func start() { | ||
| 30 | manager.requestWhenInUseAuthorization() | ||
| 31 | manager.requestLocation() | ||
| 32 | refresh() | ||
| 33 | } | ||
| 34 | |||
| 35 | func refresh(at date: Date = .now) { | ||
| 36 | guard !building else { return } | ||
| 37 | building = true | ||
| 38 | let observer = observer | ||
| 39 | Task.detached(priority: .userInitiated) { | ||
| 40 | let clock = ContinuousClock() | ||
| 41 | var scene: SkyScene? | ||
| 42 | let elapsed = clock.measure { scene = try? SkyScene(date: date, observer: observer) } | ||
| 43 | await MainActor.run { | ||
| 44 | self.scene = scene | ||
| 45 | self.lastBuild = elapsed | ||
| 46 | self.building = false | ||
| 47 | } | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { | ||
| 52 | guard let c = locations.last?.coordinate else { return } | ||
| 53 | Task { @MainActor in | ||
| 54 | self.observer = Observer(latitude: c.latitude, longitude: c.longitude) | ||
| 55 | self.hasLocation = true | ||
| 56 | self.refresh() | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { | ||
| 61 | Task { @MainActor in log.error("Location failed: \(error.localizedDescription, privacy: .public)") } | ||
| 62 | } | ||
| 63 | |||
| 64 | nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { | ||
| 65 | let status = manager.authorizationStatus | ||
| 66 | Task { @MainActor in | ||
| 67 | if [.authorizedWhenInUse, .authorizedAlways].contains(status) { self.manager.requestLocation() } | ||
| 68 | } | ||
| 69 | } | ||
| 70 | } | ||
App/Sources/SkyScreen.swift added +97
| @@ -0,0 +1,97 @@ | |||
| 1 | import SkyRenderer | ||
| 2 | import SwiftUI | ||
| 3 | |||
| 4 | struct SkyScreen: View { | ||
| 5 | @State private var model = SkyModel() | ||
| 6 | @State private var azimuth = 180.0 | ||
| 7 | @State private var altitude = 45.0 | ||
| 8 | @State private var fieldOfView = 100.0 | ||
| 9 | @GestureState private var drag: CGSize = .zero | ||
| 10 | @GestureState private var zoom: CGFloat = 1 | ||
| 11 | private let stats = DrawStats() | ||
| 12 | |||
| 13 | var body: some View { | ||
| 14 | GeometryReader { geo in | ||
| 15 | ZStack(alignment: .topLeading) { | ||
| 16 | Color(red: 0.043, green: 0.078, blue: 0.141).ignoresSafeArea() | ||
| 17 | if let scene = model.scene { | ||
| 18 | let view = current(width: geo.size.width) | ||
| 19 | SkyView(scene: scene, centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView) { [stats] d in | ||
| 20 | stats.record(d) | ||
| 21 | } | ||
| 22 | .ignoresSafeArea() | ||
| 23 | .gesture(panGesture(width: geo.size.width).simultaneously(with: zoomGesture)) | ||
| 24 | } | ||
| 25 | header | ||
| 26 | } | ||
| 27 | } | ||
| 28 | .task { | ||
| 29 | model.start() | ||
| 30 | while !Task.isCancelled { | ||
| 31 | try? await Task.sleep(for: .seconds(30)) | ||
| 32 | model.refresh() | ||
| 33 | } | ||
| 34 | } | ||
| 35 | } | ||
| 36 | |||
| 37 | /// The view direction with any in-progress gestures applied. | ||
| 38 | private func current(width: CGFloat) -> (azimuth: Double, altitude: Double, fieldOfView: Double) { | ||
| 39 | let fov = min(max(fieldOfView / Double(zoom), 30), 140) | ||
| 40 | let degreesPerPoint = fov / Double(max(width, 1)) | ||
| 41 | let az = (azimuth - Double(drag.width) * degreesPerPoint).truncatingRemainder(dividingBy: 360) | ||
| 42 | let alt = min(max(altitude + Double(drag.height) * degreesPerPoint, 0), 90) | ||
| 43 | return ((az + 360).truncatingRemainder(dividingBy: 360), alt, fov) | ||
| 44 | } | ||
| 45 | |||
| 46 | private func panGesture(width: CGFloat) -> some Gesture { | ||
| 47 | DragGesture(minimumDistance: 0) | ||
| 48 | .updating($drag) { value, state, _ in state = value.translation } | ||
| 49 | .onEnded { value in | ||
| 50 | let degreesPerPoint = fieldOfView / Double(max(width, 1)) | ||
| 51 | azimuth = ((azimuth - Double(value.translation.width) * degreesPerPoint).truncatingRemainder(dividingBy: 360) + 360).truncatingRemainder(dividingBy: 360) | ||
| 52 | altitude = min(max(altitude + Double(value.translation.height) * degreesPerPoint, 0), 90) | ||
| 53 | } | ||
| 54 | } | ||
| 55 | |||
| 56 | private var zoomGesture: some Gesture { | ||
| 57 | MagnifyGesture() | ||
| 58 | .updating($zoom) { value, state, _ in state = value.magnification } | ||
| 59 | .onEnded { value in fieldOfView = min(max(fieldOfView / Double(value.magnification), 30), 140) } | ||
| 60 | } | ||
| 61 | |||
| 62 | private var header: some View { | ||
| 63 | VStack(alignment: .leading, spacing: 4) { | ||
| 64 | Text("Tonight").font(.system(size: 22, weight: .medium)) | ||
| 65 | Text(model.hasLocation ? "Your location" : "Chicago (location unavailable)") | ||
| 66 | .font(.system(size: 11)).foregroundStyle(.secondary) | ||
| 67 | TimelineView(.periodic(from: .now, by: 1)) { _ in | ||
| 68 | Text(stats.summary(build: model.lastBuild)) | ||
| 69 | .font(.system(size: 10).monospacedDigit()).foregroundStyle(.secondary) | ||
| 70 | } | ||
| 71 | } | ||
| 72 | .foregroundStyle(Color(red: 0.95, green: 0.95, blue: 0.93)) | ||
| 73 | .padding(.horizontal, 20) | ||
| 74 | .padding(.top, 8) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | /// Draw timings for the debug readout. Written from the Canvas closure, read once a second. | ||
| 79 | final class DrawStats: @unchecked Sendable { | ||
| 80 | private let lock = NSLock() | ||
| 81 | private var samples: [Duration] = [] | ||
| 82 | |||
| 83 | func record(_ d: Duration) { | ||
| 84 | lock.withLock { | ||
| 85 | samples.append(d) | ||
| 86 | if samples.count > 120 { samples.removeFirst(samples.count - 120) } | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | func summary(build: Duration) -> String { | ||
| 91 | let s = lock.withLock { samples } | ||
| 92 | let ms = s.map { Double($0.components.attoseconds) / 1e15 + Double($0.components.seconds) * 1000 }.sorted() | ||
| 93 | guard let median = ms.isEmpty ? nil : ms[ms.count / 2], let worst = ms.last else { return "draw –" } | ||
| 94 | let b = Double(build.components.attoseconds) / 1e15 + Double(build.components.seconds) * 1000 | ||
| 95 | return String(format: "draw %.1f ms median, %.1f ms max · scene %.0f ms", median, worst, b) | ||
| 96 | } | ||
| 97 | } | ||
App/project.yml added +37
| @@ -0,0 +1,37 @@ | |||
| 1 | name: Moonrise | ||
| 2 | options: | ||
| 3 | bundleIdPrefix: sh.krz | ||
| 4 | deploymentTarget: | ||
| 5 | iOS: "26.0" | ||
| 6 | packages: | ||
| 7 | MoonriseKit: | ||
| 8 | path: ../Packages/MoonriseKit | ||
| 9 | targets: | ||
| 10 | Moonrise: | ||
| 11 | type: application | ||
| 12 | platform: iOS | ||
| 13 | sources: [Sources] | ||
| 14 | dependencies: | ||
| 15 | - package: MoonriseKit | ||
| 16 | product: SkyEngine | ||
| 17 | - package: MoonriseKit | ||
| 18 | product: SkyRenderer | ||
| 19 | entitlements: | ||
| 20 | path: Sources/Moonrise.entitlements | ||
| 21 | properties: | ||
| 22 | aps-environment: development | ||
| 23 | settings: | ||
| 24 | base: | ||
| 25 | PRODUCT_BUNDLE_IDENTIFIER: sh.krz.moonrise | ||
| 26 | DEVELOPMENT_TEAM: ZCNAX3VL9D | ||
| 27 | CODE_SIGN_STYLE: Automatic | ||
| 28 | SWIFT_VERSION: "6.0" | ||
| 29 | TARGETED_DEVICE_FAMILY: "1" | ||
| 30 | MARKETING_VERSION: "0.1" | ||
| 31 | CURRENT_PROJECT_VERSION: "1" | ||
| 32 | GENERATE_INFOPLIST_FILE: YES | ||
| 33 | INFOPLIST_KEY_CFBundleDisplayName: Moonrise | ||
| 34 | INFOPLIST_KEY_UILaunchScreen_Generation: YES | ||
| 35 | INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleLightContent | ||
| 36 | INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait | ||
| 37 | INFOPLIST_KEY_NSLocationWhenInUseUsageDescription: Moonrise uses your location to draw the sky above you. | ||
Packages/MoonriseKit/Sources/SkyRenderer/SkyView.swift +7 −2
| @@ -55,22 +55,27 @@ public struct SkyView: View { | |||
| 55 | public var centerAltitude: Double | 55 | public var centerAltitude: Double |
| 56 | public var fieldOfView: Double | 56 | public var fieldOfView: Double |
| 57 | public var style = SkyStyle() | 57 | public var style = SkyStyle() |
| 58 | /// Called after each draw with the time the draw took. | ||
| 59 | public var onDraw: (@Sendable (Duration) -> Void)? | ||
| 58 | 60 | ||
| 59 | public init(scene: SkyScene, centerAzimuth: Double = 180, centerAltitude: Double = 55, fieldOfView: Double = 110) { | 61 | public init(scene: SkyScene, centerAzimuth: Double = 180, centerAltitude: Double = 55, fieldOfView: Double = 110, onDraw: (@Sendable (Duration) -> Void)? = nil) { |
| 60 | self.scene = scene | 62 | self.scene = scene |
| 61 | self.centerAzimuth = centerAzimuth | 63 | self.centerAzimuth = centerAzimuth |
| 62 | self.centerAltitude = centerAltitude | 64 | self.centerAltitude = centerAltitude |
| 63 | self.fieldOfView = fieldOfView | 65 | self.fieldOfView = fieldOfView |
| 66 | self.onDraw = onDraw | ||
| 64 | } | 67 | } |
| 65 | 68 | ||
| 66 | public var body: some View { | 69 | public var body: some View { |
| 67 | Canvas { context, size in | 70 | Canvas { context, size in |
| 71 | let start = ContinuousClock.now | ||
| 68 | let p = Projection(centerAzimuth: centerAzimuth, centerAltitude: centerAltitude, fieldOfView: fieldOfView, size: size) | 72 | let p = Projection(centerAzimuth: centerAzimuth, centerAltitude: centerAltitude, fieldOfView: fieldOfView, size: size) |
| 69 | context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(style.background)) | 73 | context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(style.background)) |
| 70 | drawConstellations(&context, p) | 74 | drawConstellations(&context, p) |
| 71 | drawStars(&context, p) | 75 | drawStars(&context, p) |
| 72 | drawBodies(&context, p) | 76 | drawBodies(&context, p) |
| 73 | drawHorizon(&context, p, size: size) | 77 | drawHorizon(&context, p, size: size) |
| 78 | onDraw?(ContinuousClock.now - start) | ||
| 74 | } | 79 | } |
| 75 | } | 80 | } |
| 76 | 81 | ||
| @@ -147,7 +152,7 @@ public struct SkyView: View { | |||
| 147 | ground.addLine(to: CGPoint(x: first.x, y: size.height + 10)) | 152 | ground.addLine(to: CGPoint(x: first.x, y: size.height + 10)) |
| 148 | ground.closeSubpath() | 153 | ground.closeSubpath() |
| 149 | context.fill(ground, with: .color(style.ground)) | 154 | context.fill(ground, with: .color(style.ground)) |
| 150 | for (name, az) in [("SE", 135.0), ("S", 180.0), ("SW", 225.0), ("E", 90.0), ("W", 270.0)] { | 155 | for (name, az) in [("N", 0.0), ("NE", 45.0), ("E", 90.0), ("SE", 135.0), ("S", 180.0), ("SW", 225.0), ("W", 270.0), ("NW", 315.0)] { |
| 151 | guard let pt = p.point(Horizontal(altitude: 0, azimuth: az)), pt.x > 0, pt.x < size.width else { continue } | 156 | guard let pt = p.point(Horizontal(altitude: 0, azimuth: az)), pt.x > 0, pt.x < size.width else { continue } |
| 152 | label(&context, name, at: CGPoint(x: pt.x, y: pt.y + 18), anchor: .center, color: style.muted) | 157 | label(&context, name, at: CGPoint(x: pt.x, y: pt.y + 18), anchor: .center, color: style.muted) |
| 153 | } | 158 | } |