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 6fda452e77

6fda452e775e39b8ad590deecf11111fb13a7266

parent: 53de8fc315

Unsigned

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

Messier objects on the sky chart (!57)

Messier objects on the sky chart, after batching the star fills.

## Star fills (94af22b)
`SkyView.drawStars` now builds one path per bin (half a magnitude × four B−V tints, 72 bins) and fills each once, faintest first. Each star keeps its own radius. Colour and opacity are within 0.25 mag and 1/6 tint of the per-star values (`starBinsStayCloseToEachStar`).

Timings: `ImageRenderer` on an Apple silicon Mac, Chicago 2026-10-17 02:00 UT, 393×852 pt at 3× (1179×2556), default view, 8,404 stars. "Canvas" is the time spent in the Canvas closure (`onDraw`). Each figure is the median of 100 renders after 10 warm-up renders; the range covers 3 runs.

| | before | after batching | after, Messier layer on |
|-|-|-|-|
| debug, canvas, day | 2.7–2.8 ms | 2.1–3.2 ms (min 2.0) | 2.5–3.1 ms (min 2.1) |
| release, canvas, day | 1.3–1.4 ms (min 1.2) | 0.8–0.9 ms (min 0.6) | 1.7–2.0 ms (min 0.6) |
| release, canvas, night | 1.1–1.2 ms (min 1.0) | 0.7–0.8 ms (min 0.6) | 1.2–2.4 ms (min 0.7) |
| scene build, release | 1.0 ms | 1.0 ms | 1.1–2.0 ms |

The first render in a fresh process (`renderStaticSky`, the figure M0 used) is 50–180 ms both before and after, and is mostly one-time setup. The "Messier layer on" runs overlapped with builds in other worktrees: their medians are noisy, so compare the minimums.

## Data (1e35a36)
`scripts/make-messier.py` fetches OpenNGC at tag v20260501 (NGC.csv and addendum.csv) and writes `SkyRenderer/Resources/messier.json` (13 KB, 109 objects). Each object has its J2000 RA/Dec in degrees, OpenNGC type, magnitude (V, else B), major and minor axes in arcminutes, position angle, and its Library page id, taken from `link.messier` in `deep-sky-*.json`. OpenNGC lists M102 as a duplicate of M101, so M102 is left out. The M102 Library page keeps its own text ("probably the Spindle Galaxy") and is not linked from the chart. THIRD_PARTY.org and Settings > Credits now credit OpenNGC under CC BY-SA 4.0. The repo is public, so the converted file is also available without DRM.

## Chart (699b77a)
- `DeepSkyObject`, `Catalog.deepSky`, `SkyScene.deepSky`. The scene places each object and a point 0.1° north of it in one batch call. Symbols are oriented from that north point. A test checks that the east direction matches a projected point east of the object.
- Symbols are stroked in `style.line` at 0.8 pt and drawn between stars and bodies. A symbol is drawn at the object's true size, with a minimum radius of 4 pt:
  - galaxy (G): ellipse at its position angle
  - open cluster (OCl, plus M24, M40 and M73): dashed circle
  - globular (GCl): circle with a cross
  - nebula (Neb, HII, RfN, Cl+N, and SNR for M1): square
  - planetary nebula (PN): small circle with four ticks
- "M31"-style labels appear at a field of view of 60° or less. At 90° the labels pile up in Sagittarius.
- `SkyObject.deepSky(Int)`. A tap hits a Messier object within 0.6 × the tap radius, after bodies and before constellations.
- Tapping a symbol opens a card with the Library subtitle, altitude, direction, magnitude and size, plus Log and Read more (the Library page).
- Toggle: Settings > Display > Messier objects, on by default (`SettingsKey.showDeepSky`). It hides both the symbols and their hit test.

## Not verified
- Draw time on a real iPhone.
- The card, the toggle and the chart in the running app. The app builds for the iOS simulator, but I could not boot a free simulator (process limit). The symbols were checked only in `ImageRenderer` PNGs from the package tests.
- Tours do not point at Messier objects (`Tour.Target.messier` is not added).

Layout: unified · split

App/Sources/SettingsScreen.swift +6 −1
@@ -18,6 +18,8 @@ enum SettingsKey {
18 static let skyCulture = "skyCulture" 18 static let skyCulture = "skyCulture"
19 /// Minutes after local midnight. 19 /// Minutes after local midnight.
20 static let dailySummaryMinutes = "dailySummaryMinutes" 20 static let dailySummaryMinutes = "dailySummaryMinutes"
21 /// Messier objects on the sky chart.
22 static let showDeepSky = "showDeepSky"
21} 23}
22 24
23struct City: Hashable, Identifiable { 25struct City: Hashable, Identifiable {
@@ -65,6 +67,7 @@ struct SettingsScreen: View {
65 @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630 67 @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630
66 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto" 68 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto"
67 @AppStorage(SettingsKey.skyCulture) private var skyCulture = "western" 69 @AppStorage(SettingsKey.skyCulture) private var skyCulture = "western"
70 @AppStorage(SettingsKey.showDeepSky) private var showDeepSky = true
68 @AppStorage(SettingsKey.onboarded) private var onboarded = true 71 @AppStorage(SettingsKey.onboarded) private var onboarded = true
69 @Environment(\.dismiss) private var dismiss 72 @Environment(\.dismiss) private var dismiss
70 @State private var alerts = AuroraAlerts.shared 73 @State private var alerts = AuroraAlerts.shared
@@ -126,6 +129,7 @@ struct SettingsScreen: View {
126 Text("Western (IAU)").tag("western") 129 Text("Western (IAU)").tag("western")
127 ForEach(Catalog.skyCultures) { Text($0.name).tag($0.id) } 130 ForEach(Catalog.skyCultures) { Text($0.name).tag($0.id) }
128 } 131 }
132 Toggle("Messier objects", isOn: $showDeepSky)
129 Picker("Night mode", selection: $nightMode) { 133 Picker("Night mode", selection: $nightMode) {
130 Text("Off").tag("off") 134 Text("Off").tag("off")
131 Text("On").tag("on") 135 Text("On").tag("on")
@@ -134,7 +138,7 @@ struct SettingsScreen: View {
134 } header: { 138 } header: {
135 Text("Display") 139 Text("Display")
136 } footer: { 140 } 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.")
138 } 142 }
139 Section("About") { 143 Section("About") {
140 LabeledContent("Version", value: Bundle.main.versionString) 144 LabeledContent("Version", value: Bundle.main.versionString)
@@ -197,6 +201,7 @@ struct CreditsScreen: View {
197 Credit(name: "Yale Bright Star Catalogue, 5th ed.", detail: "Hoffleit & Warren (1991), via VizieR V/50."), 201 Credit(name: "Yale Bright Star Catalogue, 5th ed.", detail: "Hoffleit & Warren (1991), via VizieR V/50."),
198 Credit(name: "Constellation lines", detail: "Marc van der Sluys, ConstellationLines (doi:10.5281/zenodo.10397192). CC BY 4.0."), 202 Credit(name: "Constellation lines", detail: "Marc van der Sluys, ConstellationLines (doi:10.5281/zenodo.10397192). CC BY 4.0."),
199 Credit(name: "Coastlines", detail: "Natural Earth. Public domain."), 203 Credit(name: "Coastlines", detail: "Natural Earth. Public domain."),
204 Credit(name: "Messier objects", detail: "Mattia Verga, OpenNGC. CC BY-SA 4.0."),
200 Credit(name: "Spacecraft trajectories", detail: "NASA/JPL Horizons."), 205 Credit(name: "Spacecraft trajectories", detail: "NASA/JPL Horizons."),
201 ] 206 ]
202 207
App/Sources/SkyScreen.swift +32 −2
@@ -12,6 +12,7 @@ struct SkyScreen: View {
12 @State private var fieldOfView = 100.0 12 @State private var fieldOfView = 100.0
13 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto" 13 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto"
14 @AppStorage(SettingsKey.skyCulture) private var skyCulture = "western" 14 @AppStorage(SettingsKey.skyCulture) private var skyCulture = "western"
15 @AppStorage(SettingsKey.showDeepSky) private var showDeepSky = true
15 @State private var showSettings = false 16 @State private var showSettings = false
16 @State private var showEvents = false 17 @State private var showEvents = false
17 @State private var showLog = false 18 @State private var showLog = false
@@ -45,14 +46,14 @@ struct SkyScreen: View {
45 style.background.ignoresSafeArea() 46 style.background.ignoresSafeArea()
46 if let scene = model.scene { 47 if let scene = model.scene {
47 let view = current(width: geo.size.width) 48 let view = current(width: geo.size.width)
48 SkyView(scene: scene, centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, style: style, culture: Catalog.skyCultures.first { $0.id == skyCulture }, highlight: (tour.map { $0.steps[tourStep].target } ?? shown).flatMap(highlight)) { [stats] d in 49 SkyView(scene: scene, centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, style: style, culture: Catalog.skyCultures.first { $0.id == skyCulture }, highlight: (tour.map { $0.steps[tourStep].target } ?? shown).flatMap(highlight), showDeepSky: showDeepSky) { [stats] d in
49 stats.record(d) 50 stats.record(d)
50 } 51 }
51 .ignoresSafeArea() 52 .ignoresSafeArea()
52 .gesture(panGesture(width: geo.size.width).simultaneously(with: zoomGesture)) 53 .gesture(panGesture(width: geo.size.width).simultaneously(with: zoomGesture))
53 .onTapGesture(coordinateSpace: .local) { location in 54 .onTapGesture(coordinateSpace: .local) { location in
54 let p = Projection(centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, size: geo.size) 55 let p = Projection(centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, size: geo.size)
55 select(scene.object(at: location, projection: p)) 56 select(scene.object(at: location, projection: p, deepSky: showDeepSky))
56 } 57 }
57 .accessibilityElement() 58 .accessibilityElement()
58 .accessibilityLabel(skyDescription(scene, azimuth: view.azimuth)) 59 .accessibilityLabel(skyDescription(scene, azimuth: view.azimuth))
@@ -305,6 +306,8 @@ struct SkyScreen: View {
305 if let d = details { bodyFacts(d) } else { ProgressView().tint(ink) } 306 if let d = details { bodyFacts(d) } else { ProgressView().tint(ink) }
306 case .constellation(let id): 307 case .constellation(let id):
307 Text("Constellation · \(id)").font(.footnote).dimmed(0.7) 308 Text("Constellation · \(id)").font(.footnote).dimmed(0.7)
309 case .deepSky(let m):
310 if let d = model.scene?.deepSky.first(where: { $0.object.messier == m }) { deepSkyFacts(d) }
308 } 311 }
309 HStack(spacing: 20) { 312 HStack(spacing: 20) {
310 Button { logEntry = newObservation(title(for: object)) } label: { 313 Button { logEntry = newObservation(title(for: object)) } label: {
@@ -335,6 +338,7 @@ struct SkyScreen: View {
335 switch object { 338 switch object {
336 case .body(let b): Library.page(body: b.rawValue) 339 case .body(let b): Library.page(body: b.rawValue)
337 case .constellation(let id): Library.page(constellation: id) 340 case .constellation(let id): Library.page(constellation: id)
341 case .deepSky(let m): Catalog.deepSky.first { $0.messier == m }.flatMap { Library.page(id: $0.page) }
338 } 342 }
339 } 343 }
340 344
@@ -342,6 +346,7 @@ struct SkyScreen: View {
342 switch object { 346 switch object {
343 case .body(let b): b.rawValue.capitalized 347 case .body(let b): b.rawValue.capitalized
344 case .constellation(let id): constellationNames[id] ?? id 348 case .constellation(let id): constellationNames[id] ?? id
349 case .deepSky(let m): "M\(m)"
345 } 350 }
346 } 351 }
347 352
@@ -370,6 +375,31 @@ struct SkyScreen: View {
370 } 375 }
371 } 376 }
372 377
378 private func deepSkyFacts(_ d: PlacedDeepSky) -> some View {
379 let o = d.object
380 var rows: [(String, String)] = [
381 ("Altitude", String(format: "%.0f°", d.position.altitude)),
382 ("Direction", String(format: "%.0f° %@", d.position.azimuth, compass(d.position.azimuth))),
383 ]
384 if let m = o.magnitude { rows.append(("Magnitude", String(format: "%.1f", m))) }
385 if let a = o.majorAxis {
386 let b = o.minorAxis.map { String(format: " × %.0f", $0) } ?? ""
387 rows.append(("Size", String(format: "%.0f%@′", a, b)))
388 }
389 return VStack(alignment: .leading, spacing: 8) {
390 if let page = Library.page(id: o.page) { Text(page.subtitle).font(.footnote).dimmed(0.7) }
391 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) {
392 ForEach(rows, id: \.0) { row in
393 GridRow {
394 Text(row.0).dimmed(0.7)
395 Text(row.1).monospacedDigit()
396 }
397 .font(.footnote)
398 }
399 }
400 }
401 }
402
373 /// VoiceOver summary: which way the view faces and what bright bodies are up. 403 /// VoiceOver summary: which way the view faces and what bright bodies are up.
374 private func skyDescription(_ scene: SkyScene, azimuth: Double) -> String { 404 private func skyDescription(_ scene: SkyScene, azimuth: Double) -> String {
375 let up = scene.bodies.filter { $0.position.altitude > 0 && $0.magnitude <= 6 }.map { b -> String in 405 let up = scene.bodies.filter { $0.position.altitude > 0 && $0.magnitude <= 6 }.map { b -> String in
Packages/MoonriseKit/Package.swift +1 −1
@@ -23,7 +23,7 @@ let package = Package(
23 .target( 23 .target(
24 name: "SkyRenderer", 24 name: "SkyRenderer",
25 dependencies: ["SkyEngine"], 25 dependencies: ["SkyEngine"],
26 resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json"), .copy("Resources/coastline.json"), .copy("Resources/spacecraft.json"), .copy("Resources/skycultures.json")] 26 resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json"), .copy("Resources/coastline.json"), .copy("Resources/spacecraft.json"), .copy("Resources/skycultures.json"), .copy("Resources/messier.json")]
27 ), 27 ),
28 .target(name: "Feeds"), 28 .target(name: "Feeds"),
29 .target(name: "ObservingLog"), 29 .target(name: "ObservingLog"),
Packages/MoonriseKit/Sources/SkyRenderer/HitTest.swift +14 −3
@@ -5,12 +5,15 @@ public enum SkyObject: Sendable, Equatable {
5 case body(Body) 5 case body(Body)
6 /// IAU abbreviation. 6 /// IAU abbreviation.
7 case constellation(String) 7 case constellation(String)
8 /// Messier number.
9 case deepSky(Int)
8} 10}
9 11
10extension SkyScene { 12extension SkyScene {
11 /// The object under a tap: the nearest visible sun, moon or planet within 13 /// The object under a tap: the nearest visible sun, moon or planet within `radius` points,
12 /// `radius` points, else the constellation whose figure has a star nearest the tap. 14 /// else the nearest Messier object within 0.6 × `radius` (when `deepSky`),
13 public func object(at point: CGPoint, projection p: Projection, radius: CGFloat = 28) -> SkyObject? { 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? {
14 func distance(_ h: Horizontal) -> CGFloat? { 17 func distance(_ h: Horizontal) -> CGFloat? {
15 guard h.altitude > 0, let q = p.point(h) else { return nil } 18 guard h.altitude > 0, let q = p.point(h) else { return nil }
16 return hypot(q.x - point.x, q.y - point.y) 19 return hypot(q.x - point.x, q.y - point.y)
@@ -22,6 +25,14 @@ extension SkyScene {
22 .min { $0.1 < $1.1 } 25 .min { $0.1 < $1.1 }
23 if let bodyHit { return .body(bodyHit.0) } 26 if let bodyHit { return .body(bodyHit.0) }
24 27
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
25 var best: (String, CGFloat)? 36 var best: (String, CGFloat)?
26 for c in Catalog.constellations { 37 for c in Catalog.constellations {
27 for hr in c.lines.joined() { 38 for hr in c.lines.joined() {
Packages/MoonriseKit/Sources/SkyRenderer/Resources/messier.json added +1
@@ -0,0 +1 @@
1[{"messier":1,"type":"SNR","ra":83.63321,"dec":22.01447,"magnitude":8.4,"majorAxis":8.0,"minorAxis":4.0,"page":"m1"},{"messier":2,"type":"GCl","ra":323.36254,"dec":-0.82331,"magnitude":6.25,"majorAxis":8.4,"page":"m2"},{"messier":3,"type":"GCl","ra":205.54679,"dec":28.37544,"magnitude":6.39,"majorAxis":16.2,"page":"m3"},{"messier":4,"type":"GCl","ra":245.8975,"dec":-26.52553,"magnitude":5.4,"majorAxis":28.2,"page":"m4"},{"messier":5,"type":"GCl","ra":229.64063,"dec":2.08269,"magnitude":5.95,"majorAxis":15.0,"page":"m5"},{"messier":6,"type":"OCl","ra":265.08646,"dec":-32.25417,"magnitude":4.2,"majorAxis":15.6,"page":"m6"},{"messier":7,"type":"OCl","ra":268.46325,"dec":-34.79283,"magnitude":3.3,"majorAxis":22.2,"page":"m7"},{"messier":8,"type":"Neb","ra":270.92196,"dec":-24.38017,"magnitude":5.8,"majorAxis":45.0,"minorAxis":30.0,"page":"m8"},{"messier":9,"type":"GCl","ra":259.79908,"dec":-18.51625,"magnitude":8.42,"majorAxis":6.9,"page":"m9"},{"messier":10,"type":"GCl","ra":254.28746,"dec":-4.09933,"magnitude":4.98,"majorAxis":9.3,"page":"m10"},{"messier":11,"type":"OCl","ra":282.77496,"dec":-6.27003,"magnitude":5.8,"majorAxis":9.0,"page":"m11"},{"messier":12,"type":"GCl","ra":251.8105,"dec":-1.94783,"magnitude":6.07,"majorAxis":11.1,"page":"m12"},{"messier":13,"type":"GCl","ra":250.42346,"dec":36.46131,"magnitude":5.8,"majorAxis":16.5,"page":"m13"},{"messier":14,"type":"GCl","ra":264.40067,"dec":-3.24592,"magnitude":5.73,"majorAxis":9.9,"page":"m14"},{"messier":15,"type":"GCl","ra":322.49325,"dec":12.16683,"magnitude":6.3,"majorAxis":11.1,"page":"m15"},{"messier":16,"type":"Neb","ra":274.70071,"dec":-13.80722,"magnitude":6.0,"majorAxis":120.0,"minorAxis":25.0,"page":"m16"},{"messier":17,"type":"Neb","ra":275.19629,"dec":-16.17153,"magnitude":7.0,"majorAxis":12.6,"page":"m17"},{"messier":18,"type":"OCl","ra":274.99371,"dec":-17.10197,"magnitude":6.9,"majorAxis":6.0,"page":"m18"},{"messier":19,"type":"GCl","ra":255.657,"dec":-26.26794,"magnitude":5.57,"majorAxis":7.5,"page":"m19"},{"messier":20,"type":"Neb","ra":270.67546,"dec":-22.97189,"magnitude":8.5,"majorAxis":28.0,"minorAxis":28.0,"page":"m20"},{"messier":21,"type":"OCl","ra":271.05604,"dec":-22.49006,"magnitude":5.9,"majorAxis":6.0,"page":"m21"},{"messier":22,"type":"GCl","ra":279.10083,"dec":-23.90342,"magnitude":6.17,"majorAxis":12.6,"page":"m22"},{"messier":23,"type":"OCl","ra":269.26988,"dec":-18.98533,"magnitude":5.5,"majorAxis":16.8,"page":"m23"},{"messier":24,"type":"*Ass","ra":274.23383,"dec":-18.51456,"magnitude":4.5,"majorAxis":120.0,"minorAxis":60.0,"positionAngle":90.0,"page":"m24"},{"messier":25,"type":"OCl","ra":277.94488,"dec":-19.11494,"magnitude":4.6,"majorAxis":14.1,"page":"m25"},{"messier":26,"type":"OCl","ra":281.32775,"dec":-9.38361,"magnitude":8.87,"majorAxis":6.0,"page":"m26"},{"messier":27,"type":"PN","ra":299.90158,"dec":22.72103,"magnitude":7.4,"majorAxis":6.7,"page":"m27"},{"messier":28,"type":"GCl","ra":276.13704,"dec":-24.86983,"magnitude":6.9,"majorAxis":5.1,"page":"m28"},{"messier":29,"type":"OCl","ra":305.99071,"dec":38.50767,"magnitude":6.6,"majorAxis":3.6,"page":"m29"},{"messier":30,"type":"GCl","ra":325.09175,"dec":-23.17908,"magnitude":7.1,"majorAxis":9.0,"page":"m30"},{"messier":31,"type":"G","ra":10.68479,"dec":41.26906,"magnitude":3.44,"majorAxis":177.83,"minorAxis":69.66,"positionAngle":35.0,"page":"m31"},{"messier":32,"type":"G","ra":10.67429,"dec":40.86528,"magnitude":8.13,"majorAxis":7.74,"minorAxis":4.86,"positionAngle":170.0,"page":"m32"},{"messier":33,"type":"G","ra":23.46204,"dec":30.66022,"magnitude":5.79,"majorAxis":62.09,"minorAxis":36.73,"positionAngle":23.0,"page":"m33"},{"messier":34,"type":"OCl","ra":40.53083,"dec":42.74614,"magnitude":5.2,"majorAxis":22.5,"page":"m34"},{"messier":35,"type":"OCl","ra":92.27108,"dec":24.33864,"magnitude":5.1,"majorAxis":24.0,"page":"m35"},{"messier":36,"type":"OCl","ra":84.07392,"dec":34.14075,"magnitude":6.0,"majorAxis":7.2,"page":"m36"},{"messier":37,"type":"OCl","ra":88.07646,"dec":32.553,"magnitude":5.6,"majorAxis":11.4,"page":"m37"},{"messier":38,"type":"OCl","ra":82.17704,"dec":35.85492,"magnitude":6.4,"majorAxis":9.6,"page":"m38"},{"messier":39,"type":"OCl","ra":322.95133,"dec":48.43817,"magnitude":4.6,"majorAxis":19.5,"page":"m39"},{"messier":40,"type":"**","ra":185.56708,"dec":58.08444,"magnitude":8.0,"page":"m40"},{"messier":41,"type":"OCl","ra":101.49975,"dec":-20.75422,"magnitude":4.5,"majorAxis":12.0,"page":"m41"},{"messier":42,"type":"Cl+N","ra":83.81867,"dec":-5.38967,"magnitude":4.0,"majorAxis":90.0,"minorAxis":60.0,"page":"m42"},{"messier":43,"type":"HII","ra":83.88075,"dec":-5.26747,"magnitude":9.0,"majorAxis":20.0,"minorAxis":15.0,"page":"m43"},{"messier":44,"type":"OCl","ra":130.0925,"dec":19.67206,"magnitude":3.1,"majorAxis":108.6,"page":"m44"},{"messier":45,"type":"OCl","ra":56.86917,"dec":24.10528,"magnitude":1.2,"majorAxis":150.0,"minorAxis":150.0,"positionAngle":90.0,"page":"m45"},{"messier":46,"type":"OCl","ra":115.44508,"dec":-14.81,"magnitude":6.1,"majorAxis":21.0,"page":"m46"},{"messier":47,"type":"OCl","ra":114.14592,"dec":-14.48261,"magnitude":4.4,"majorAxis":19.8,"page":"m47"},{"messier":48,"type":"OCl","ra":123.42992,"dec":-5.75044,"magnitude":5.8,"majorAxis":28.2,"page":"m48"},{"messier":49,"type":"G","ra":187.44483,"dec":8.00047,"magnitude":8.28,"majorAxis":10.21,"minorAxis":8.38,"positionAngle":156.0,"page":"m49"},{"messier":50,"type":"OCl","ra":105.66863,"dec":-8.36403,"magnitude":5.9,"majorAxis":14.1,"page":"m50"},{"messier":51,"type":"G","ra":202.46962,"dec":47.19517,"magnitude":8.36,"majorAxis":13.71,"minorAxis":11.67,"positionAngle":163.0,"page":"m51"},{"messier":52,"type":"OCl","ra":351.20167,"dec":61.59317,"magnitude":6.9,"majorAxis":9.9,"page":"m52"},{"messier":53,"type":"GCl","ra":198.23012,"dec":18.16911,"magnitude":7.79,"majorAxis":9.0,"page":"m53"},{"messier":54,"type":"GCl","ra":283.76363,"dec":-30.4785,"magnitude":7.7,"majorAxis":5.1,"page":"m54"},{"messier":55,"type":"GCl","ra":294.9975,"dec":-30.96208,"magnitude":6.49,"majorAxis":12.0,"page":"m55"},{"messier":56,"type":"GCl","ra":289.14796,"dec":30.1845,"magnitude":8.4,"majorAxis":5.8,"page":"m56"},{"messier":57,"type":"PN","ra":283.39587,"dec":33.02858,"magnitude":8.8,"majorAxis":1.27,"page":"m57"},{"messier":58,"type":"G","ra":189.43133,"dec":11.81819,"magnitude":10.3,"majorAxis":5.01,"minorAxis":3.84,"positionAngle":90.0,"page":"m58"},{"messier":59,"type":"G","ra":190.50933,"dec":11.64703,"magnitude":9.56,"majorAxis":4.55,"minorAxis":3.21,"positionAngle":165.0,"page":"m59"},{"messier":60,"type":"G","ra":190.91658,"dec":11.55269,"magnitude":8.79,"majorAxis":6.78,"minorAxis":5.45,"positionAngle":105.0,"page":"m60"},{"messier":61,"type":"G","ra":185.47875,"dec":4.47364,"magnitude":10.25,"majorAxis":6.89,"minorAxis":6.56,"positionAngle":20.0,"page":"m61"},{"messier":62,"type":"GCl","ra":255.3025,"dec":-30.11236,"magnitude":7.39,"majorAxis":7.8,"page":"m62"},{"messier":63,"type":"G","ra":198.95554,"dec":42.02928,"magnitude":8.61,"majorAxis":11.83,"minorAxis":7.16,"positionAngle":103.0,"page":"m63"},{"messier":64,"type":"G","ra":194.18183,"dec":21.68297,"magnitude":8.52,"majorAxis":10.52,"minorAxis":5.33,"positionAngle":114.0,"page":"m64"},{"messier":65,"type":"G","ra":169.733,"dec":13.09236,"magnitude":9.32,"majorAxis":7.64,"minorAxis":1.97,"positionAngle":173.0,"page":"m65"},{"messier":66,"type":"G","ra":170.06233,"dec":12.99153,"magnitude":8.92,"majorAxis":10.28,"minorAxis":4.61,"positionAngle":168.0,"page":"m66"},{"messier":67,"type":"OCl","ra":132.83388,"dec":11.81194,"magnitude":6.9,"majorAxis":33.0,"page":"m67"},{"messier":68,"type":"GCl","ra":189.86671,"dec":-26.74303,"magnitude":7.96,"majorAxis":6.6,"page":"m68"},{"messier":69,"type":"GCl","ra":277.84679,"dec":-32.34797,"magnitude":8.31,"majorAxis":5.7,"page":"m69"},{"messier":70,"type":"GCl","ra":280.80267,"dec":-32.29189,"magnitude":9.06,"majorAxis":6.6,"page":"m70"},{"messier":71,"type":"GCl","ra":298.44212,"dec":18.77839,"magnitude":6.1,"majorAxis":6.9,"page":"m71"},{"messier":72,"type":"GCl","ra":313.36629,"dec":-12.53706,"magnitude":8.96,"majorAxis":4.5,"page":"m72"},{"messier":73,"type":"Other","ra":314.73321,"dec":-12.6355,"magnitude":8.9,"page":"m73"},{"messier":74,"type":"G","ra":24.17396,"dec":15.78367,"magnitude":9.31,"majorAxis":9.89,"minorAxis":9.33,"positionAngle":87.0,"page":"m74"},{"messier":75,"type":"GCl","ra":301.52017,"dec":-21.92222,"magnitude":8.26,"majorAxis":3.6,"page":"m75"},{"messier":76,"type":"PN","ra":25.58204,"dec":51.57547,"magnitude":10.1,"majorAxis":1.12,"page":"m76"},{"messier":77,"type":"G","ra":40.66963,"dec":-0.01328,"magnitude":9.29,"majorAxis":6.11,"minorAxis":5.61,"positionAngle":12.0,"page":"m77"},{"messier":78,"type":"RfN","ra":86.69092,"dec":0.07931,"magnitude":8.0,"majorAxis":4.5,"page":"m78"},{"messier":79,"type":"GCl","ra":81.04413,"dec":-24.52422,"magnitude":8.16,"majorAxis":7.2,"page":"m79"},{"messier":80,"type":"GCl","ra":244.26046,"dec":-22.97511,"magnitude":7.3,"majorAxis":5.7,"page":"m80"},{"messier":81,"type":"G","ra":148.88821,"dec":69.06531,"magnitude":6.92,"majorAxis":21.63,"minorAxis":11.25,"positionAngle":157.0,"page":"m81"},{"messier":82,"type":"G","ra":148.96971,"dec":69.67939,"magnitude":8.3,"majorAxis":10.99,"minorAxis":5.11,"positionAngle":66.0,"page":"m82"},{"messier":83,"type":"G","ra":204.25396,"dec":-29.86542,"magnitude":7.21,"majorAxis":13.61,"minorAxis":13.21,"positionAngle":45.0,"page":"m83"},{"messier":84,"type":"G","ra":186.26558,"dec":12.88697,"magnitude":9.79,"majorAxis":7.41,"minorAxis":6.44,"positionAngle":133.0,"page":"m84"},{"messier":85,"type":"G","ra":186.35046,"dec":18.1915,"magnitude":9.05,"majorAxis":6.95,"minorAxis":5.35,"positionAngle":12.0,"page":"m85"},{"messier":86,"type":"G","ra":186.54892,"dec":12.94622,"magnitude":8.86,"majorAxis":11.53,"minorAxis":8.43,"positionAngle":128.0,"page":"m86"},{"messier":87,"type":"G","ra":187.70592,"dec":12.39111,"magnitude":9.0,"majorAxis":7.11,"minorAxis":6.67,"positionAngle":153.0,"page":"m87"},{"messier":88,"type":"G","ra":187.9965,"dec":14.42039,"magnitude":10.33,"majorAxis":8.65,"minorAxis":4.38,"positionAngle":138.0,"page":"m88"},{"messier":89,"type":"G","ra":188.91587,"dec":12.55633,"magnitude":10.08,"majorAxis":8.13,"minorAxis":8.0,"positionAngle":150.0,"page":"m89"},{"messier":90,"type":"G","ra":189.20746,"dec":13.16294,"magnitude":9.54,"majorAxis":9.12,"minorAxis":3.82,"positionAngle":22.0,"page":"m90"},{"messier":91,"type":"G","ra":188.86021,"dec":14.49633,"magnitude":10.96,"majorAxis":5.55,"minorAxis":4.52,"positionAngle":150.0,"page":"m91"},{"messier":92,"type":"GCl","ra":259.28029,"dec":43.13653,"magnitude":6.52,"majorAxis":14.4,"page":"m92"},{"messier":93,"type":"OCl","ra":116.12179,"dec":-23.85308,"magnitude":6.2,"majorAxis":15.0,"page":"m93"},{"messier":94,"type":"G","ra":192.72108,"dec":41.12044,"magnitude":8.24,"majorAxis":7.74,"minorAxis":6.68,"positionAngle":105.0,"page":"m94"},{"messier":95,"type":"G","ra":160.99042,"dec":11.70381,"magnitude":9.77,"majorAxis":7.23,"minorAxis":4.45,"positionAngle":11.0,"page":"m95"},{"messier":96,"type":"G","ra":161.69058,"dec":11.81994,"magnitude":9.21,"majorAxis":8.26,"minorAxis":5.51,"positionAngle":5.0,"page":"m96"},{"messier":97,"type":"PN","ra":168.69879,"dec":55.01903,"magnitude":9.9,"majorAxis":3.58,"page":"m97"},{"messier":98,"type":"G","ra":183.45121,"dec":14.90033,"magnitude":10.84,"majorAxis":11.04,"minorAxis":2.66,"positionAngle":152.0,"page":"m98"},{"messier":99,"type":"G","ra":184.70667,"dec":14.4165,"magnitude":9.84,"majorAxis":5.04,"minorAxis":4.74,"positionAngle":23.0,"page":"m99"},{"messier":100,"type":"G","ra":185.72846,"dec":15.82181,"magnitude":9.47,"majorAxis":6.1,"minorAxis":5.62,"positionAngle":108.0,"page":"m100"},{"messier":101,"type":"G","ra":210.80225,"dec":54.34894,"magnitude":7.9,"majorAxis":23.99,"minorAxis":23.07,"positionAngle":28.0,"page":"m101"},{"messier":103,"type":"OCl","ra":23.34088,"dec":60.658,"magnitude":7.4,"majorAxis":4.5,"page":"m103"},{"messier":104,"type":"G","ra":189.99762,"dec":-11.62306,"magnitude":8.59,"majorAxis":8.45,"minorAxis":4.91,"positionAngle":90.0,"page":"m104"},{"messier":105,"type":"G","ra":161.95663,"dec":12.58161,"magnitude":9.27,"majorAxis":4.89,"minorAxis":4.25,"positionAngle":71.0,"page":"m105"},{"messier":106,"type":"G","ra":184.73958,"dec":47.30397,"magnitude":9.29,"majorAxis":16.98,"minorAxis":7.24,"positionAngle":150.0,"page":"m106"},{"messier":107,"type":"GCl","ra":248.133,"dec":-13.05364,"magnitude":8.85,"majorAxis":7.8,"page":"m107"},{"messier":108,"type":"G","ra":167.87904,"dec":55.67411,"magnitude":10.05,"majorAxis":3.98,"minorAxis":1.66,"positionAngle":79.0,"page":"m108"},{"messier":109,"type":"G","ra":179.39992,"dec":53.37453,"magnitude":9.88,"majorAxis":8.07,"minorAxis":5.64,"positionAngle":78.0,"page":"m109"},{"messier":110,"type":"G","ra":10.092,"dec":41.68531,"magnitude":8.15,"majorAxis":16.22,"minorAxis":9.59,"positionAngle":170.0,"page":"m110"}]\ No newline at end of file
Packages/MoonriseKit/Sources/SkyRenderer/SkyScene.swift +47
@@ -43,6 +43,37 @@ public struct SkyCulture: Sendable, Decodable, Identifiable {
43 public var constellations: [Figure] 43 public var constellations: [Figure]
44} 44}
45 45
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
46public enum Catalog { 77public enum Catalog {
47 /// Yale Bright Star Catalogue, 5th ed. (VizieR V/50): HR, RA and Dec (J2000, degrees), V, B−V, 78 /// Yale Bright Star Catalogue, 5th ed. (VizieR V/50): HR, RA and Dec (J2000, degrees), V, B−V,
48 /// proper motion in RA (× cos dec) and Dec (arcsec/yr). 79 /// proper motion in RA (× cos dec) and Dec (arcsec/yr).
@@ -65,6 +96,12 @@ public enum Catalog {
65 let url = Bundle.module.url(forResource: "constellations", withExtension: "json")! 96 let url = Bundle.module.url(forResource: "constellations", withExtension: "json")!
66 return try! JSONDecoder().decode([Constellation].self, from: Data(contentsOf: url)) 97 return try! JSONDecoder().decode([Constellation].self, from: Data(contentsOf: url))
67 }() 98 }()
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 }()
68} 105}
69 106
70public struct PlacedStar: Sendable { 107public struct PlacedStar: Sendable {
@@ -72,6 +109,13 @@ public struct PlacedStar: Sendable {
72 public var position: Horizontal 109 public var position: Horizontal
73} 110}
74 111
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
75public struct PlacedBody: Sendable { 119public struct PlacedBody: Sendable {
76 public var body: Body 120 public var body: Body
77 public var position: Horizontal 121 public var position: Horizontal
@@ -86,6 +130,7 @@ public struct SkyScene: Sendable {
86 public var stars: [PlacedStar] 130 public var stars: [PlacedStar]
87 public var starsByHR: [Int: Horizontal] 131 public var starsByHR: [Int: Horizontal]
88 public var bodies: [PlacedBody] 132 public var bodies: [PlacedBody]
133 public var deepSky: [PlacedDeepSky]
89 /// Moon's elongation east of the sun, degrees (0 new, 180 full). 134 /// Moon's elongation east of the sun, degrees (0 new, 180 full).
90 public var moonPhase: Double 135 public var moonPhase: Double
91 136
@@ -96,6 +141,8 @@ public struct SkyScene: Sendable {
96 let positions = SkyEngine.horizontal(j2000: visible.map { $0.position(at: date) }, at: date, from: observer) 141 let positions = SkyEngine.horizontal(j2000: visible.map { $0.position(at: date) }, at: date, from: observer)
97 stars = zip(visible, positions).map { PlacedStar(star: $0, position: $1) } 142 stars = zip(visible, positions).map { PlacedStar(star: $0, position: $1) }
98 starsByHR = Dictionary(uniqueKeysWithValues: stars.map { ($0.star.hr, $0.position) }) 143 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]) }
99 bodies = try ([.moon] + Body.planets).map { body in 146 bodies = try ([.moon] + Body.planets).map { body in
100 let i = try SkyEngine.illumination(body, at: date) 147 let i = try SkyEngine.illumination(body, at: date)
101 return PlacedBody( 148 return PlacedBody(
Packages/MoonriseKit/Sources/SkyRenderer/SkyView.swift +87 −6
@@ -67,14 +67,32 @@ public struct SkyStyle: Sendable {
67 return s 67 return s
68 }() 68 }()
69 69
70 /// Star tint from B−V colour index, kept pale. 70 /// Star colour for a tint from 0 (blue-white) to 1 (orange), kept pale.
71 func starColor(_ bv: Double) -> Color { 71 func starColor(tint t: Double) -> Color {
72 if let monochromeStars { return monochromeStars } 72 if let monochromeStars { return monochromeStars }
73 let t = min(max((bv + 0.3) / 2.0, 0), 1)
74 return Color(red: 0.80 + 0.20 * t, green: 0.86 - 0.06 * t, blue: 1.0 - 0.32 * t) 73 return Color(red: 0.80 + 0.20 * t, green: 0.86 - 0.06 * t, blue: 1.0 - 0.32 * t)
75 } 74 }
76} 75}
77 76
77/// Stars are filled one path per bin: half a magnitude by four tints from B−V.
78struct StarBin {
79 static let tints = 4
80 /// Magnitudes −2 to 6.5.
81 static let count = 18 * tints
82 var index: Int
83
84 init(index: Int) { self.index = index }
85
86 init(magnitude: Double, colorIndex: Double) {
87 let m = min(max(Int(((magnitude + 2) * 2).rounded()), 0), 17)
88 let t = min(max(Int(((colorIndex + 0.3) / 2 * Double(Self.tints - 1)).rounded()), 0), Self.tints - 1)
89 index = m * Self.tints + t
90 }
91
92 var magnitude: Double { Double(index / Self.tints) / 2 - 2 }
93 var tint: Double { Double(index % Self.tints) / Double(Self.tints - 1) }
94}
95
78/// Something to draw attention to, e.g. the current stop of a guided tour. 96/// Something to draw attention to, e.g. the current stop of a guided tour.
79public enum SkyHighlight: Sendable, Equatable { 97public enum SkyHighlight: Sendable, Equatable {
80 /// IAU abbreviation. 98 /// IAU abbreviation.
@@ -93,14 +111,16 @@ public struct SkyView: View {
93 /// Figures to draw instead of the IAU constellation lines. 111 /// Figures to draw instead of the IAU constellation lines.
94 public var culture: SkyCulture? 112 public var culture: SkyCulture?
95 public var highlight: SkyHighlight? 113 public var highlight: SkyHighlight?
114 public var showDeepSky: Bool
96 /// Called after each draw with the time the draw took. 115 /// Called after each draw with the time the draw took.
97 public var onDraw: (@Sendable (Duration) -> Void)? 116 public var onDraw: (@Sendable (Duration) -> Void)?
98 117
99 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) {
100 self.scene = scene 119 self.scene = scene
101 self.style = style 120 self.style = style
102 self.culture = culture 121 self.culture = culture
103 self.highlight = highlight 122 self.highlight = highlight
123 self.showDeepSky = showDeepSky
104 self.centerAzimuth = centerAzimuth 124 self.centerAzimuth = centerAzimuth
105 self.centerAltitude = centerAltitude 125 self.centerAltitude = centerAltitude
106 self.fieldOfView = fieldOfView 126 self.fieldOfView = fieldOfView
@@ -114,6 +134,7 @@ public struct SkyView: View {
114 context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(style.background)) 134 context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(style.background))
115 drawConstellations(&context, p) 135 drawConstellations(&context, p)
116 drawStars(&context, p) 136 drawStars(&context, p)
137 if showDeepSky { drawDeepSky(&context, p) }
117 drawBodies(&context, p) 138 drawBodies(&context, p)
118 if let highlight { drawHighlight(highlight, &context, p) } 139 if let highlight { drawHighlight(highlight, &context, p) }
119 drawHorizon(&context, p, size: size) 140 drawHorizon(&context, p, size: size)
@@ -193,13 +214,73 @@ public struct SkyView: View {
193 } 214 }
194 } 215 }
195 216
217 /// Each star at its own radius; colour and opacity per `StarBin`, faintest bins first.
196 func drawStars(_ context: inout GraphicsContext, _ p: Projection) { 218 func drawStars(_ context: inout GraphicsContext, _ p: Projection) {
219 var bins = [Path](repeating: Path(), count: StarBin.count)
197 for s in scene.stars where s.position.altitude > 0 { 220 for s in scene.stars where s.position.altitude > 0 {
198 guard let pt = p.point(s.position) else { continue } 221 guard let pt = p.point(s.position) else { continue }
199 let r = max(0.3, 2.6 - 0.42 * s.star.magnitude) 222 let r = max(0.3, 2.6 - 0.42 * s.star.magnitude)
200 let opacity = min(1, max(0.25, 1.15 - 0.13 * s.star.magnitude)) 223 bins[StarBin(magnitude: s.star.magnitude, colorIndex: s.star.colorIndex).index]
201 context.fill(Path(ellipseIn: CGRect(x: pt.x - r, y: pt.y - r, width: 2 * r, height: 2 * r)), with: .color(style.starColor(s.star.colorIndex).opacity(opacity))) 224 .addEllipse(in: CGRect(x: pt.x - r, y: pt.y - r, width: 2 * r, height: 2 * r))
225 }
226 for i in bins.indices.reversed() where !bins[i].isEmpty {
227 let bin = StarBin(index: i)
228 let opacity = min(1, max(0.25, 1.15 - 0.13 * bin.magnitude))
229 context.fill(bins[i], with: .color(style.starColor(tint: bin.tint).opacity(opacity)))
230 }
231 }
232
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 }
202 } 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]))
203 } 284 }
204 285
205 func drawBodies(_ context: inout GraphicsContext, _ p: Projection) { 286 func drawBodies(_ context: inout GraphicsContext, _ p: Projection) {
Packages/MoonriseKit/Tests/LibraryTests/LibraryTests.swift +6
@@ -42,6 +42,12 @@ func linksPointAtThingsTheAppKnows(_ p: LibraryPage) {
42 if let s = l.spacecraft { #expect(Spacecraft.all.contains { $0.name == s }, "\(p.id): spacecraft \(s)") } 42 if let s = l.spacecraft { #expect(Spacecraft.all.contains { $0.name == s }, "\(p.id): spacecraft \(s)") }
43} 43}
44 44
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
45@Test func searchFindsByTitleBeforeEssay() { 51@Test func searchFindsByTitleBeforeEssay() {
46 guard let first = Library.pages.first else { return } 52 guard let first = Library.pages.first else { return }
47 #expect(Library.search(first.title).first?.id == first.id) 53 #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}
Packages/MoonriseKit/Tests/SkyRendererTests/SkyRenderTests.swift +12
@@ -56,6 +56,18 @@ let evening = Date(timeIntervalSince1970: 1_792_202_400) // 2026-10-17T02:00:00Z
56 #expect(scene.object(at: a, projection: p) == .constellation("Aql")) 56 #expect(scene.object(at: a, projection: p) == .constellation("Aql"))
57} 57}
58 58
59@Test func starBinsStayCloseToEachStar() {
60 var used = Set<Int>()
61 for star in Catalog.stars where star.magnitude <= 6.5 {
62 let bin = StarBin(magnitude: star.magnitude, colorIndex: star.colorIndex)
63 #expect(abs(bin.magnitude - star.magnitude) <= 0.25, "HR \(star.hr)")
64 let tint = min(max((star.colorIndex + 0.3) / 2, 0), 1)
65 #expect(abs(bin.tint - tint) <= 1.0 / 6 + 1e-9, "HR \(star.hr)")
66 used.insert(bin.index)
67 }
68 #expect(used.allSatisfy { (0..<StarBin.count).contains($0) })
69}
70
59@MainActor 71@MainActor
60@Test func nightStyleRenders() throws { 72@Test func nightStyleRenders() throws {
61 let scene = try SkyScene(date: evening, observer: chicago) 73 let scene = try SkyScene(date: evening, observer: chicago)
THIRD_PARTY.org +1
@@ -6,6 +6,7 @@
6| Yale Bright Star Catalogue, 5th rev. ed. (Hoffleit & Warren 1991) | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/bsc5.json= | See VizieR policy; licence statement pending | https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50 | 6| Yale Bright Star Catalogue, 5th rev. ed. (Hoffleit & Warren 1991) | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/bsc5.json= | See VizieR policy; licence statement pending | https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50 |
7| BSC5 proper motions (pmRA, pmDE) | same file, columns 6 and 7 | as above | https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50 | 7| BSC5 proper motions (pmRA, pmDE) | same file, columns 6 and 7 | as above | https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50 |
8| Stellarium sky cultures: Chinese, Indian, Mongolian, Japanese moon stations, Hawaiian starlines, Māori, Tongan, Boorong, Sámi, Norse (lines and names, converted to HR numbers by =scripts/make-skycultures.py=) | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/skycultures.json= | CC BY-SA (per culture, stated in each culture's description.md); the converted file is under the same licence | https://github.com/Stellarium/stellarium-skycultures | 8| Stellarium sky cultures: Chinese, Indian, Mongolian, Japanese moon stations, Hawaiian starlines, Māori, Tongan, Boorong, Sámi, Norse (lines and names, converted to HR numbers by =scripts/make-skycultures.py=) | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/skycultures.json= | CC BY-SA (per culture, stated in each culture's description.md); the converted file is under the same licence | https://github.com/Stellarium/stellarium-skycultures |
9| OpenNGC v20260501, Mattia Verga: Messier objects (position, type, magnitude, axes, position angle; converted by =scripts/make-messier.py=). M102 is left out: OpenNGC lists it as a duplicate of M101 | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/messier.json= | CC BY-SA 4.0; the converted file is under the same licence | https://github.com/mattiaverga/OpenNGC |
9| Hipparcos main catalogue positions (used only to match HIP to HR when building skycultures.json; not bundled) | — | ESA | https://cdsarc.cds.unistra.fr/viz-bin/cat/I/239 | 10| Hipparcos main catalogue positions (used only to match HIP to HR when building skycultures.json; not bundled) | — | ESA | https://cdsarc.cds.unistra.fr/viz-bin/cat/I/239 |
10| Global maps for globes (Mercury PIA15063; Venus Magellan C3-MDIR colourised topography; Earth Blue Marble Next Generation, December 2004; Moon SVS 4720 LROC colour 2K; Mars Viking MDIM 2.1 colour; Jupiter PIA07782; Pluto New Horizons global mosaic; Io, Europa, Ganymede, Callisto USGS Galileo/Voyager mosaics; Titan PIA22770; Enceladus Schenk 2024), downscaled to at most 2048 px | =App/Globes/*.jpg= | Public domain (NASA, USGS); credit per map in =App/Sources/GlobeMaps.swift= | https://science.nasa.gov/photojournal, https://svs.gsfc.nasa.gov/4720/, https://astrogeology.usgs.gov | 11| Global maps for globes (Mercury PIA15063; Venus Magellan C3-MDIR colourised topography; Earth Blue Marble Next Generation, December 2004; Moon SVS 4720 LROC colour 2K; Mars Viking MDIM 2.1 colour; Jupiter PIA07782; Pluto New Horizons global mosaic; Io, Europa, Ganymede, Callisto USGS Galileo/Voyager mosaics; Titan PIA22770; Enceladus Schenk 2024), downscaled to at most 2048 px | =App/Globes/*.jpg= | Public domain (NASA, USGS); credit per map in =App/Sources/GlobeMaps.swift= | https://science.nasa.gov/photojournal, https://svs.gsfc.nasa.gov/4720/, https://astrogeology.usgs.gov |
11| Sun texture for the 3D solar system (=App/Globes/sun.jpg=) | generated noise, made for this app | — | — | 12| Sun texture for the 3D solar system (=App/Globes/sun.jpg=) | generated noise, made for this app | — | — |
scripts/make-messier.py added +73
@@ -0,0 +1,73 @@
1#!/usr/bin/env python3
2"""Builds SkyRenderer/Resources/messier.json from OpenNGC (CC BY-SA 4.0), pinned to v20260501.
3
4Usage: make-messier.py
5
6Rows with a Messier number in NGC.csv and addendum.csv (M40, M45). Coordinates are J2000 degrees,
7axes arcminutes, position angle degrees east of north; magnitude is V, else B. Each object carries the
8id of its Library page (link.messier in pages/deep-sky-*.json).
9
10M102: OpenNGC lists it as a duplicate observation of M101 (addendum type "Dup"), so it is left out
11and the file has 109 objects. The Library page for M102 keeps its own account (probably NGC 5866).
12"""
13import csv
14import io
15import json
16import urllib.request
17from pathlib import Path
18
19ROOT = Path(__file__).resolve().parent.parent
20RES = ROOT / "Packages/MoonriseKit/Sources/SkyRenderer/Resources"
21PAGES = ROOT / "Packages/MoonriseKit/Sources/Library/Resources/pages"
22VERSION = "v20260501"
23BASE = f"https://raw.githubusercontent.com/mattiaverga/OpenNGC/{VERSION}/database_files"
24
25
26def fetch(url):
27 with urllib.request.urlopen(url, timeout=60) as r:
28 return r.read().decode("utf-8")
29
30
31def sexagesimal(s, hours):
32 sign = -1 if s.startswith("-") else 1
33 d, m, sec = (float(x) for x in s.lstrip("+-").split(":"))
34 v = sign * (d + m / 60 + sec / 3600)
35 return v * 15 if hours else v
36
37
38def number(s):
39 return float(s) if s.strip() else None
40
41
42def main():
43 pages = {}
44 for f in sorted(PAGES.glob("deep-sky-*.json")):
45 for p in json.loads(f.read_text()):
46 pages[p["link"]["messier"]] = p["id"]
47
48 out = {}
49 for name in ("NGC.csv", "addendum.csv"):
50 for row in csv.DictReader(io.StringIO(fetch(f"{BASE}/{name}")), delimiter=";"):
51 if not row["M"] or row["Type"] == "Dup":
52 continue
53 m = int(row["M"])
54 assert m not in out, m
55 o = {
56 "messier": m,
57 "type": row["Type"],
58 "ra": round(sexagesimal(row["RA"], True), 5),
59 "dec": round(sexagesimal(row["Dec"], False), 5),
60 "magnitude": number(row["V-Mag"]) if row["V-Mag"].strip() else number(row["B-Mag"]),
61 "majorAxis": number(row["MajAx"]),
62 "minorAxis": number(row["MinAx"]),
63 "positionAngle": number(row["PosAng"]),
64 "page": pages[m],
65 }
66 out[m] = {k: v for k, v in o.items() if v is not None}
67
68 assert sorted(out) == [m for m in range(1, 111) if m != 102], sorted(set(range(1, 111)) - set(out))
69 (RES / "messier.json").write_text(json.dumps([out[m] for m in sorted(out)], ensure_ascii=False, separators=(",", ":")))
70
71
72if __name__ == "__main__":
73 main()