M1: app shell with live sky !2
8 files changed, +266 −2
Layout: unified · split
.gitignore +1
| @@ -5,3 +5,4 @@ node_modules/ | ||
| 5 | 5 | .wrangler/ |
| 6 | 6 | .dev.vars |
| 7 | 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 | 55 | public var centerAltitude: Double |
| 56 | 56 | public var fieldOfView: Double |
| 57 | 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 | 62 | self.scene = scene |
| 61 | 63 | self.centerAzimuth = centerAzimuth |
| 62 | 64 | self.centerAltitude = centerAltitude |
| 63 | 65 | self.fieldOfView = fieldOfView |
| 66 | self.onDraw = onDraw | |
| 64 | 67 | } |
| 65 | 68 | |
| 66 | 69 | public var body: some View { |
| 67 | 70 | Canvas { context, size in |
| 71 | let start = ContinuousClock.now | |
| 68 | 72 | let p = Projection(centerAzimuth: centerAzimuth, centerAltitude: centerAltitude, fieldOfView: fieldOfView, size: size) |
| 69 | 73 | context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(style.background)) |
| 70 | 74 | drawConstellations(&context, p) |
| 71 | 75 | drawStars(&context, p) |
| 72 | 76 | drawBodies(&context, p) |
| 73 | 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 | 152 | ground.addLine(to: CGPoint(x: first.x, y: size.height + 10)) |
| 148 | 153 | ground.closeSubpath() |
| 149 | 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 | 156 | guard let pt = p.point(Horizontal(altitude: 0, azimuth: az)), pt.x > 0, pt.x < size.width else { continue } |
| 152 | 157 | label(&context, name, at: CGPoint(x: pt.x, y: pt.y + 18), anchor: .center, color: style.muted) |
| 153 | 158 | } |