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 699b77afc9

699b77afc99fe40452797d393ff1afdeb2eea0a2

parent: 1e35a36894

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-19 21:39 UTC

Messier objects on the sky chart

Atlas symbols between stars and bodies: galaxy ellipse at size and
position angle, dashed circle for open clusters, crossed circle for
globulars, square for nebulae, ticked circle for planetary nebulae.
Labels at 60 degrees field of view and closer. Tapping a symbol opens a
card with altitude, direction, magnitude and size, linking to the
Library page. Settings > Display > Messier objects, on by default.

Layout: unified · split

App/Sources/SettingsScreen.swift +6 −1
@@ -18,6 +18,8 @@ enum SettingsKey {
1818 static let skyCulture = "skyCulture"
1919 /// Minutes after local midnight.
2020 static let dailySummaryMinutes = "dailySummaryMinutes"
21 /// Messier objects on the sky chart.
22 static let showDeepSky = "showDeepSky"
2123}
2224
2325struct City: Hashable, Identifiable {
@@ -65,6 +67,7 @@ struct SettingsScreen: View {
6567 @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630
6668 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto"
6769 @AppStorage(SettingsKey.skyCulture) private var skyCulture = "western"
70 @AppStorage(SettingsKey.showDeepSky) private var showDeepSky = true
6871 @AppStorage(SettingsKey.onboarded) private var onboarded = true
6972 @Environment(\.dismiss) private var dismiss
7073 @State private var alerts = AuroraAlerts.shared
@@ -126,6 +129,7 @@ struct SettingsScreen: View {
126129 Text("Western (IAU)").tag("western")
127130 ForEach(Catalog.skyCultures) { Text($0.name).tag($0.id) }
128131 }
132 Toggle("Messier objects", isOn: $showDeepSky)
129133 Picker("Night mode", selection: $nightMode) {
130134 Text("Off").tag("off")
131135 Text("On").tag("on")
@@ -134,7 +138,7 @@ struct SettingsScreen: View {
134138 } header: {
135139 Text("Display")
136140 } footer: {
137 Text("Constellations from other cultures come from Stellarium's sky cultures and are drawn over the same stars; figures with stars fainter than the app's catalogue are shortened. Night mode draws the sky in red to keep your eyes adapted to the dark. After dark turns it on once the Sun is 12° below the horizon.")
141 Text("Constellations from other cultures come from Stellarium's sky cultures and are drawn over the same stars; figures with stars fainter than the app's catalogue are shortened. Messier objects are galaxies, clusters and nebulae, drawn with atlas symbols; most need binoculars. Night mode draws the sky in red to keep your eyes adapted to the dark. After dark turns it on once the Sun is 12° below the horizon.")
138142 }
139143 Section("About") {
140144 LabeledContent("Version", value: Bundle.main.versionString)
@@ -197,6 +201,7 @@ struct CreditsScreen: View {
197201 Credit(name: "Yale Bright Star Catalogue, 5th ed.", detail: "Hoffleit & Warren (1991), via VizieR V/50."),
198202 Credit(name: "Constellation lines", detail: "Marc van der Sluys, ConstellationLines (doi:10.5281/zenodo.10397192). CC BY 4.0."),
199203 Credit(name: "Coastlines", detail: "Natural Earth. Public domain."),
204 Credit(name: "Messier objects", detail: "Mattia Verga, OpenNGC. CC BY-SA 4.0."),
200205 Credit(name: "Spacecraft trajectories", detail: "NASA/JPL Horizons."),
201206 ]
202207
App/Sources/SkyScreen.swift +32 −2
@@ -12,6 +12,7 @@ struct SkyScreen: View {
1212 @State private var fieldOfView = 100.0
1313 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto"
1414 @AppStorage(SettingsKey.skyCulture) private var skyCulture = "western"
15 @AppStorage(SettingsKey.showDeepSky) private var showDeepSky = true
1516 @State private var showSettings = false
1617 @State private var showEvents = false
1718 @State private var showLog = false
@@ -42,14 +43,14 @@ struct SkyScreen: View {
4243 style.background.ignoresSafeArea()
4344 if let scene = model.scene {
4445 let view = current(width: geo.size.width)
45 SkyView(scene: scene, centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, style: style, culture: Catalog.skyCultures.first { $0.id == skyCulture }, highlight: tour.flatMap { highlight($0.steps[tourStep].target) }) { [stats] d in
46 SkyView(scene: scene, centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, style: style, culture: Catalog.skyCultures.first { $0.id == skyCulture }, highlight: tour.flatMap { highlight($0.steps[tourStep].target) }, showDeepSky: showDeepSky) { [stats] d in
4647 stats.record(d)
4748 }
4849 .ignoresSafeArea()
4950 .gesture(panGesture(width: geo.size.width).simultaneously(with: zoomGesture))
5051 .onTapGesture(coordinateSpace: .local) { location in
5152 let p = Projection(centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, size: geo.size)
52 select(scene.object(at: location, projection: p))
53 select(scene.object(at: location, projection: p, deepSky: showDeepSky))
5354 }
5455 .accessibilityElement()
5556 .accessibilityLabel(skyDescription(scene, azimuth: view.azimuth))
@@ -290,6 +291,8 @@ struct SkyScreen: View {
290291 if let d = details { bodyFacts(d) } else { ProgressView().tint(ink) }
291292 case .constellation(let id):
292293 Text("Constellation · \(id)").font(.footnote).dimmed(0.7)
294 case .deepSky(let m):
295 if let d = model.scene?.deepSky.first(where: { $0.object.messier == m }) { deepSkyFacts(d) }
293296 }
294297 HStack(spacing: 20) {
295298 Button { logEntry = newObservation(title(for: object)) } label: {
@@ -320,6 +323,7 @@ struct SkyScreen: View {
320323 switch object {
321324 case .body(let b): Library.page(body: b.rawValue)
322325 case .constellation(let id): Library.page(constellation: id)
326 case .deepSky(let m): Catalog.deepSky.first { $0.messier == m }.flatMap { Library.page(id: $0.page) }
323327 }
324328 }
325329
@@ -327,6 +331,7 @@ struct SkyScreen: View {
327331 switch object {
328332 case .body(let b): b.rawValue.capitalized
329333 case .constellation(let id): constellationNames[id] ?? id
334 case .deepSky(let m): "M\(m)"
330335 }
331336 }
332337
@@ -355,6 +360,31 @@ struct SkyScreen: View {
355360 }
356361 }
357362
363 private func deepSkyFacts(_ d: PlacedDeepSky) -> some View {
364 let o = d.object
365 var rows: [(String, String)] = [
366 ("Altitude", String(format: "%.0f°", d.position.altitude)),
367 ("Direction", String(format: "%.0f° %@", d.position.azimuth, compass(d.position.azimuth))),
368 ]
369 if let m = o.magnitude { rows.append(("Magnitude", String(format: "%.1f", m))) }
370 if let a = o.majorAxis {
371 let b = o.minorAxis.map { String(format: " × %.0f", $0) } ?? ""
372 rows.append(("Size", String(format: "%.0f%@′", a, b)))
373 }
374 return VStack(alignment: .leading, spacing: 8) {
375 if let page = Library.page(id: o.page) { Text(page.subtitle).font(.footnote).dimmed(0.7) }
376 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) {
377 ForEach(rows, id: \.0) { row in
378 GridRow {
379 Text(row.0).dimmed(0.7)
380 Text(row.1).monospacedDigit()
381 }
382 .font(.footnote)
383 }
384 }
385 }
386 }
387
358388 /// VoiceOver summary: which way the view faces and what bright bodies are up.
359389 private func skyDescription(_ scene: SkyScene, azimuth: Double) -> String {
360390 let up = scene.bodies.filter { $0.position.altitude > 0 && $0.magnitude <= 6 }.map { b -> String in
Packages/MoonriseKit/Sources/SkyRenderer/HitTest.swift +14 −3
@@ -5,12 +5,15 @@ public enum SkyObject: Sendable, Equatable {
55 case body(Body)
66 /// IAU abbreviation.
77 case constellation(String)
8 /// Messier number.
9 case deepSky(Int)
810}
911
1012extension SkyScene {
11 /// The object under a tap: the nearest visible sun, moon or planet within
12 /// `radius` points, else the constellation whose figure has a star nearest the tap.
13 public func object(at point: CGPoint, projection p: Projection, radius: CGFloat = 28) -> SkyObject? {
13 /// The object under a tap: the nearest visible sun, moon or planet within `radius` points,
14 /// else the nearest Messier object within 0.6 × `radius` (when `deepSky`),
15 /// else the constellation whose figure has a star nearest the tap.
16 public func object(at point: CGPoint, projection p: Projection, radius: CGFloat = 28, deepSky: Bool = true) -> SkyObject? {
1417 func distance(_ h: Horizontal) -> CGFloat? {
1518 guard h.altitude > 0, let q = p.point(h) else { return nil }
1619 return hypot(q.x - point.x, q.y - point.y)
@@ -22,6 +25,14 @@ extension SkyScene {
2225 .min { $0.1 < $1.1 }
2326 if let bodyHit { return .body(bodyHit.0) }
2427
28 if deepSky {
29 let hit = self.deepSky
30 .compactMap { d in distance(d.position).map { (d.object.messier, $0) } }
31 .filter { $0.1 <= radius * 0.6 }
32 .min { $0.1 < $1.1 }
33 if let hit { return .deepSky(hit.0) }
34 }
35
2536 var best: (String, CGFloat)?
2637 for c in Catalog.constellations {
2738 for hr in c.lines.joined() {
Packages/MoonriseKit/Sources/SkyRenderer/SkyScene.swift +47
@@ -43,6 +43,37 @@ public struct SkyCulture: Sendable, Decodable, Identifiable {
4343 public var constellations: [Figure]
4444}
4545
46/// A Messier object from OpenNGC (CC BY-SA 4.0).
47public struct DeepSkyObject: Sendable, Decodable {
48 public enum Kind: Sendable { case galaxy, openCluster, globularCluster, nebula, planetaryNebula }
49
50 public var messier: Int
51 /// OpenNGC type code, e.g. "G", "OCl", "GCl", "PN", "Neb".
52 public var type: String
53 /// J2000, degrees.
54 public var ra: Double
55 public var dec: Double
56 public var magnitude: Double?
57 /// Arcminutes.
58 public var majorAxis: Double?
59 public var minorAxis: Double?
60 /// Degrees east of north.
61 public var positionAngle: Double?
62 /// Library page id.
63 public var page: String
64
65 public var kind: Kind {
66 switch type {
67 case "G": .galaxy
68 case "GCl": .globularCluster
69 case "PN": .planetaryNebula
70 case "Neb", "HII", "RfN", "Cl+N", "SNR": .nebula
71 // OCl, and the star cloud M24, double star M40 and asterism M73.
72 default: .openCluster
73 }
74 }
75}
76
4677public enum Catalog {
4778 /// Yale Bright Star Catalogue, 5th ed. (VizieR V/50): HR, RA and Dec (J2000, degrees), V, B−V,
4879 /// proper motion in RA (× cos dec) and Dec (arcsec/yr).
@@ -65,6 +96,12 @@ public enum Catalog {
6596 let url = Bundle.module.url(forResource: "constellations", withExtension: "json")!
6697 return try! JSONDecoder().decode([Constellation].self, from: Data(contentsOf: url))
6798 }()
99
100 /// Built by scripts/make-messier.py.
101 public static let deepSky: [DeepSkyObject] = {
102 let url = Bundle.module.url(forResource: "messier", withExtension: "json")!
103 return try! JSONDecoder().decode([DeepSkyObject].self, from: Data(contentsOf: url))
104 }()
68105}
69106
70107public struct PlacedStar: Sendable {
@@ -72,6 +109,13 @@ public struct PlacedStar: Sendable {
72109 public var position: Horizontal
73110}
74111
112public struct PlacedDeepSky: Sendable {
113 public var object: DeepSkyObject
114 public var position: Horizontal
115 /// A point 0.1° north (J2000), for the symbol's scale and orientation on screen.
116 public var north: Horizontal
117}
118
75119public struct PlacedBody: Sendable {
76120 public var body: Body
77121 public var position: Horizontal
@@ -86,6 +130,7 @@ public struct SkyScene: Sendable {
86130 public var stars: [PlacedStar]
87131 public var starsByHR: [Int: Horizontal]
88132 public var bodies: [PlacedBody]
133 public var deepSky: [PlacedDeepSky]
89134 /// Moon's elongation east of the sun, degrees (0 new, 180 full).
90135 public var moonPhase: Double
91136
@@ -96,6 +141,8 @@ public struct SkyScene: Sendable {
96141 let positions = SkyEngine.horizontal(j2000: visible.map { $0.position(at: date) }, at: date, from: observer)
97142 stars = zip(visible, positions).map { PlacedStar(star: $0, position: $1) }
98143 starsByHR = Dictionary(uniqueKeysWithValues: stars.map { ($0.star.hr, $0.position) })
144 let dso = SkyEngine.horizontal(j2000: Catalog.deepSky.flatMap { [($0.ra, $0.dec), ($0.ra, $0.dec + 0.1)] }, at: date, from: observer)
145 deepSky = Catalog.deepSky.enumerated().map { i, o in PlacedDeepSky(object: o, position: dso[2 * i], north: dso[2 * i + 1]) }
99146 bodies = try ([.moon] + Body.planets).map { body in
100147 let i = try SkyEngine.illumination(body, at: date)
101148 return PlacedBody(
Packages/MoonriseKit/Sources/SkyRenderer/SkyView.swift +57 −1
@@ -111,14 +111,16 @@ public struct SkyView: View {
111111 /// Figures to draw instead of the IAU constellation lines.
112112 public var culture: SkyCulture?
113113 public var highlight: SkyHighlight?
114 public var showDeepSky: Bool
114115 /// Called after each draw with the time the draw took.
115116 public var onDraw: (@Sendable (Duration) -> Void)?
116117
117 public init(scene: SkyScene, centerAzimuth: Double = 180, centerAltitude: Double = 55, fieldOfView: Double = 110, style: SkyStyle = SkyStyle(), culture: SkyCulture? = nil, highlight: SkyHighlight? = nil, onDraw: (@Sendable (Duration) -> Void)? = nil) {
118 public init(scene: SkyScene, centerAzimuth: Double = 180, centerAltitude: Double = 55, fieldOfView: Double = 110, style: SkyStyle = SkyStyle(), culture: SkyCulture? = nil, highlight: SkyHighlight? = nil, showDeepSky: Bool = true, onDraw: (@Sendable (Duration) -> Void)? = nil) {
118119 self.scene = scene
119120 self.style = style
120121 self.culture = culture
121122 self.highlight = highlight
123 self.showDeepSky = showDeepSky
122124 self.centerAzimuth = centerAzimuth
123125 self.centerAltitude = centerAltitude
124126 self.fieldOfView = fieldOfView
@@ -132,6 +134,7 @@ public struct SkyView: View {
132134 context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(style.background))
133135 drawConstellations(&context, p)
134136 drawStars(&context, p)
137 if showDeepSky { drawDeepSky(&context, p) }
135138 drawBodies(&context, p)
136139 if let highlight { drawHighlight(highlight, &context, p) }
137140 drawHorizon(&context, p, size: size)
@@ -227,6 +230,59 @@ public struct SkyView: View {
227230 }
228231 }
229232
233 /// Screen centre of a deep-sky object, and the screen vectors of one degree north and east there.
234 func symbolFrame(_ d: PlacedDeepSky, _ p: Projection) -> (center: CGPoint, north: CGVector, east: CGVector)? {
235 guard let c = p.point(d.position), let n = p.point(d.north) else { return nil }
236 let north = CGVector(dx: (n.x - c.x) * 10, dy: (n.y - c.y) * 10)
237 // Seen from inside the sphere, east is 90° anticlockwise from north; with y down that is (n.y, −n.x).
238 return (c, north, CGVector(dx: north.dy, dy: -north.dx))
239 }
240
241 /// Atlas symbols at true size, or at least `minimum` points in radius: galaxy as an ellipse at its
242 /// position angle, open cluster as a dashed circle, globular as a circle with a cross, nebula as
243 /// a square, planetary nebula as a small circle with four ticks. Labelled at 60° field of view and closer.
244 func drawDeepSky(_ context: inout GraphicsContext, _ p: Projection) {
245 let minimum = 4.0
246 var solid = Path(), dashed = Path()
247 for d in scene.deepSky where d.position.altitude > 0 {
248 guard let f = symbolFrame(d, p) else { continue }
249 let o = d.object, c = f.center
250 let perArcmin = hypot(f.north.dx, f.north.dy) / 60
251 let r = max(minimum, (o.majorAxis ?? 0) / 2 * perArcmin)
252 let circle = CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)
253 switch o.kind {
254 case .galaxy:
255 let ratio = (o.minorAxis ?? o.majorAxis ?? 1) / (o.majorAxis ?? 1)
256 let pa = (o.positionAngle ?? 0) * .pi / 180
257 let axis = CGVector(dx: cos(pa) * f.north.dx + sin(pa) * f.east.dx, dy: cos(pa) * f.north.dy + sin(pa) * f.east.dy)
258 let rotation = CGAffineTransform(rotationAngle: atan2(axis.dy, axis.dx)).concatenating(CGAffineTransform(translationX: c.x, y: c.y))
259 solid.addPath(Path(ellipseIn: CGRect(x: -r, y: -max(1.5, r * ratio), width: 2 * r, height: 2 * max(1.5, r * ratio))), transform: rotation)
260 case .openCluster:
261 dashed.addEllipse(in: circle)
262 case .globularCluster:
263 solid.addEllipse(in: circle)
264 solid.move(to: CGPoint(x: c.x - r, y: c.y))
265 solid.addLine(to: CGPoint(x: c.x + r, y: c.y))
266 solid.move(to: CGPoint(x: c.x, y: c.y - r))
267 solid.addLine(to: CGPoint(x: c.x, y: c.y + r))
268 case .nebula:
269 solid.addRect(circle)
270 case .planetaryNebula:
271 let small = minimum * 0.6
272 solid.addEllipse(in: CGRect(x: c.x - small, y: c.y - small, width: 2 * small, height: 2 * small))
273 for (dx, dy) in [(1.0, 0.0), (-1, 0), (0, 1), (0, -1)] {
274 solid.move(to: CGPoint(x: c.x + dx * small, y: c.y + dy * small))
275 solid.addLine(to: CGPoint(x: c.x + dx * 2 * small, y: c.y + dy * 2 * small))
276 }
277 }
278 if fieldOfView <= 60 {
279 label(&context, "M\(o.messier)", at: CGPoint(x: c.x, y: c.y + r + 2), anchor: .top, color: style.muted)
280 }
281 }
282 context.stroke(solid, with: .color(style.line), lineWidth: 0.8)
283 context.stroke(dashed, with: .color(style.line), style: StrokeStyle(lineWidth: 0.8, dash: [2, 2]))
284 }
285
230286 func drawBodies(_ context: inout GraphicsContext, _ p: Projection) {
231287 // Naked-eye bodies only; Neptune (about mag 7.8) is left out.
232288 for b in scene.bodies where b.position.altitude > 0 && b.magnitude <= 6 {
Packages/MoonriseKit/Tests/LibraryTests/LibraryTests.swift +6
@@ -42,6 +42,12 @@ func linksPointAtThingsTheAppKnows(_ p: LibraryPage) {
4242 if let s = l.spacecraft { #expect(Spacecraft.all.contains { $0.name == s }, "\(p.id): spacecraft \(s)") }
4343}
4444
45@Test func chartMessierObjectsOpenTheirPages() {
46 for o in Catalog.deepSky {
47 #expect(Library.page(id: o.page)?.link.messier == o.messier, "M\(o.messier): page \(o.page)")
48 }
49}
50
4551@Test func searchFindsByTitleBeforeEssay() {
4652 guard let first = Library.pages.first else { return }
4753 #expect(Library.search(first.title).first?.id == first.id)
Packages/MoonriseKit/Tests/SkyRendererTests/DeepSkyTests.swift added +56
@@ -0,0 +1,56 @@
1import Foundation
2import SkyEngine
3import SwiftUI
4import Testing
5@testable import SkyRenderer
6
7@Test func messierCatalogueFollowsOpenNGC() throws {
8 let objects = Catalog.deepSky
9 // M102 is a duplicate of M101 in OpenNGC.
10 #expect(objects.map(\.messier) == Array(1...101) + Array(103...110))
11 let m31 = try #require(objects.first { $0.messier == 31 })
12 #expect(m31.kind == .galaxy)
13 #expect(m31.positionAngle == 35)
14 #expect(abs(m31.ra - 10.685) < 0.01 && abs(m31.dec - 41.269) < 0.01)
15 #expect(objects.first { $0.messier == 13 }?.kind == .globularCluster)
16 #expect(objects.first { $0.messier == 57 }?.kind == .planetaryNebula)
17 #expect(objects.first { $0.messier == 42 }?.kind == .nebula)
18 #expect(objects.first { $0.messier == 45 }?.kind == .openCluster)
19 #expect(objects.allSatisfy { $0.page == "m\($0.messier)" })
20}
21
22@Test func tapOnASymbolFindsTheMessierObject() throws {
23 let scene = try SkyScene(date: evening, observer: chicago)
24 let m57 = try #require(scene.deepSky.first { $0.object.messier == 57 })
25 #expect(m57.position.altitude > 30)
26 let p = Projection(centerAzimuth: m57.position.azimuth, centerAltitude: m57.position.altitude, fieldOfView: 60, size: CGSize(width: 393, height: 852))
27 let pt = try #require(p.point(m57.position))
28 #expect(scene.object(at: CGPoint(x: pt.x + 4, y: pt.y), projection: p) == .deepSky(57))
29 #expect(scene.object(at: CGPoint(x: pt.x + 4, y: pt.y), projection: p, deepSky: false) != .deepSky(57))
30}
31
32/// The symbol frame's east vector matches where a point east of the object projects.
33@MainActor
34@Test func symbolFrameHasEastAnticlockwiseFromNorth() throws {
35 let scene = try SkyScene(date: evening, observer: chicago)
36 let view = SkyView(scene: scene)
37 for m in [31, 13, 57] {
38 let d = try #require(scene.deepSky.first { $0.object.messier == m })
39 let p = Projection(centerAzimuth: 180, centerAltitude: 45, fieldOfView: 100, size: CGSize(width: 393, height: 852))
40 let f = try #require(view.symbolFrame(d, p))
41 let o = d.object
42 let east = SkyEngine.horizontal(j2000: [(o.ra + 0.1 / cos(o.dec * .pi / 180), o.dec)], at: evening, from: chicago)[0]
43 let e = try #require(p.point(east))
44 let measured = CGVector(dx: (e.x - f.center.x) * 10, dy: (e.y - f.center.y) * 10)
45 #expect(hypot(measured.dx - f.east.dx, measured.dy - f.east.dy) < 0.05 * hypot(f.east.dx, f.east.dy), "M\(m)")
46 }
47}
48
49@MainActor
50@Test func deepSkyRendersAtEveryZoom() throws {
51 let scene = try SkyScene(date: evening, observer: chicago)
52 for fov in [30.0, 100.0] {
53 let renderer = ImageRenderer(content: SkyView(scene: scene, centerAzimuth: 90, centerAltitude: 50, fieldOfView: fov, showDeepSky: true).frame(width: 393, height: 852))
54 #expect(renderer.cgImage != nil)
55 }
56}