Commit 6fda452e77
Unsigned
Layout: unified · split
App/Sources/SettingsScreen.swift +6 −1
| @@ -18,6 +18,8 @@ enum SettingsKey { | ||
| 18 | 18 | static let skyCulture = "skyCulture" |
| 19 | 19 | /// Minutes after local midnight. |
| 20 | 20 | static let dailySummaryMinutes = "dailySummaryMinutes" |
| 21 | /// Messier objects on the sky chart. | |
| 22 | static let showDeepSky = "showDeepSky" | |
| 21 | 23 | } |
| 22 | 24 | |
| 23 | 25 | struct City: Hashable, Identifiable { |
| @@ -65,6 +67,7 @@ struct SettingsScreen: View { | ||
| 65 | 67 | @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630 |
| 66 | 68 | @AppStorage(SettingsKey.nightMode) private var nightMode = "auto" |
| 67 | 69 | @AppStorage(SettingsKey.skyCulture) private var skyCulture = "western" |
| 70 | @AppStorage(SettingsKey.showDeepSky) private var showDeepSky = true | |
| 68 | 71 | @AppStorage(SettingsKey.onboarded) private var onboarded = true |
| 69 | 72 | @Environment(\.dismiss) private var dismiss |
| 70 | 73 | @State private var alerts = AuroraAlerts.shared |
| @@ -126,6 +129,7 @@ struct SettingsScreen: View { | ||
| 126 | 129 | Text("Western (IAU)").tag("western") |
| 127 | 130 | ForEach(Catalog.skyCultures) { Text($0.name).tag($0.id) } |
| 128 | 131 | } |
| 132 | Toggle("Messier objects", isOn: $showDeepSky) | |
| 129 | 133 | Picker("Night mode", selection: $nightMode) { |
| 130 | 134 | Text("Off").tag("off") |
| 131 | 135 | Text("On").tag("on") |
| @@ -134,7 +138,7 @@ struct SettingsScreen: View { | ||
| 134 | 138 | } header: { |
| 135 | 139 | Text("Display") |
| 136 | 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 | 143 | Section("About") { |
| 140 | 144 | LabeledContent("Version", value: Bundle.main.versionString) |
| @@ -197,6 +201,7 @@ struct CreditsScreen: View { | ||
| 197 | 201 | Credit(name: "Yale Bright Star Catalogue, 5th ed.", detail: "Hoffleit & Warren (1991), via VizieR V/50."), |
| 198 | 202 | Credit(name: "Constellation lines", detail: "Marc van der Sluys, ConstellationLines (doi:10.5281/zenodo.10397192). CC BY 4.0."), |
| 199 | 203 | Credit(name: "Coastlines", detail: "Natural Earth. Public domain."), |
| 204 | Credit(name: "Messier objects", detail: "Mattia Verga, OpenNGC. CC BY-SA 4.0."), | |
| 200 | 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 | 12 | @State private var fieldOfView = 100.0 |
| 13 | 13 | @AppStorage(SettingsKey.nightMode) private var nightMode = "auto" |
| 14 | 14 | @AppStorage(SettingsKey.skyCulture) private var skyCulture = "western" |
| 15 | @AppStorage(SettingsKey.showDeepSky) private var showDeepSky = true | |
| 15 | 16 | @State private var showSettings = false |
| 16 | 17 | @State private var showEvents = false |
| 17 | 18 | @State private var showLog = false |
| @@ -45,14 +46,14 @@ struct SkyScreen: View { | ||
| 45 | 46 | style.background.ignoresSafeArea() |
| 46 | 47 | if let scene = model.scene { |
| 47 | 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 | 50 | stats.record(d) |
| 50 | 51 | } |
| 51 | 52 | .ignoresSafeArea() |
| 52 | 53 | .gesture(panGesture(width: geo.size.width).simultaneously(with: zoomGesture)) |
| 53 | 54 | .onTapGesture(coordinateSpace: .local) { location in |
| 54 | 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 | 58 | .accessibilityElement() |
| 58 | 59 | .accessibilityLabel(skyDescription(scene, azimuth: view.azimuth)) |
| @@ -305,6 +306,8 @@ struct SkyScreen: View { | ||
| 305 | 306 | if let d = details { bodyFacts(d) } else { ProgressView().tint(ink) } |
| 306 | 307 | case .constellation(let id): |
| 307 | 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 | 312 | HStack(spacing: 20) { |
| 310 | 313 | Button { logEntry = newObservation(title(for: object)) } label: { |
| @@ -335,6 +338,7 @@ struct SkyScreen: View { | ||
| 335 | 338 | switch object { |
| 336 | 339 | case .body(let b): Library.page(body: b.rawValue) |
| 337 | 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 | 346 | switch object { |
| 343 | 347 | case .body(let b): b.rawValue.capitalized |
| 344 | 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 | 403 | /// VoiceOver summary: which way the view faces and what bright bodies are up. |
| 374 | 404 | private func skyDescription(_ scene: SkyScene, azimuth: Double) -> String { |
| 375 | 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 | 23 | .target( |
| 24 | 24 | name: "SkyRenderer", |
| 25 | 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 | 28 | .target(name: "Feeds"), |
| 29 | 29 | .target(name: "ObservingLog"), |
Packages/MoonriseKit/Sources/SkyRenderer/HitTest.swift +14 −3
| @@ -5,12 +5,15 @@ public enum SkyObject: Sendable, Equatable { | ||
| 5 | 5 | case body(Body) |
| 6 | 6 | /// IAU abbreviation. |
| 7 | 7 | case constellation(String) |
| 8 | /// Messier number. | |
| 9 | case deepSky(Int) | |
| 8 | 10 | } |
| 9 | 11 | |
| 10 | 12 | extension 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? { | |
| 14 | 17 | func distance(_ h: Horizontal) -> CGFloat? { |
| 15 | 18 | guard h.altitude > 0, let q = p.point(h) else { return nil } |
| 16 | 19 | return hypot(q.x - point.x, q.y - point.y) |
| @@ -22,6 +25,14 @@ extension SkyScene { | ||
| 22 | 25 | .min { $0.1 < $1.1 } |
| 23 | 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 | 36 | var best: (String, CGFloat)? |
| 26 | 37 | for c in Catalog.constellations { |
| 27 | 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 | 43 | public var constellations: [Figure] |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | /// A Messier object from OpenNGC (CC BY-SA 4.0). | |
| 47 | public 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 | ||
| 46 | 77 | public enum Catalog { |
| 47 | 78 | /// Yale Bright Star Catalogue, 5th ed. (VizieR V/50): HR, RA and Dec (J2000, degrees), V, B−V, |
| 48 | 79 | /// proper motion in RA (× cos dec) and Dec (arcsec/yr). |
| @@ -65,6 +96,12 @@ public enum Catalog { | ||
| 65 | 96 | let url = Bundle.module.url(forResource: "constellations", withExtension: "json")! |
| 66 | 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 | |
| 70 | 107 | public struct PlacedStar: Sendable { |
| @@ -72,6 +109,13 @@ public struct PlacedStar: Sendable { | ||
| 72 | 109 | public var position: Horizontal |
| 73 | 110 | } |
| 74 | 111 | |
| 112 | public 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 | ||
| 75 | 119 | public struct PlacedBody: Sendable { |
| 76 | 120 | public var body: Body |
| 77 | 121 | public var position: Horizontal |
| @@ -86,6 +130,7 @@ public struct SkyScene: Sendable { | ||
| 86 | 130 | public var stars: [PlacedStar] |
| 87 | 131 | public var starsByHR: [Int: Horizontal] |
| 88 | 132 | public var bodies: [PlacedBody] |
| 133 | public var deepSky: [PlacedDeepSky] | |
| 89 | 134 | /// Moon's elongation east of the sun, degrees (0 new, 180 full). |
| 90 | 135 | public var moonPhase: Double |
| 91 | 136 | |
| @@ -96,6 +141,8 @@ public struct SkyScene: Sendable { | ||
| 96 | 141 | let positions = SkyEngine.horizontal(j2000: visible.map { $0.position(at: date) }, at: date, from: observer) |
| 97 | 142 | stars = zip(visible, positions).map { PlacedStar(star: $0, position: $1) } |
| 98 | 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 | 146 | bodies = try ([.moon] + Body.planets).map { body in |
| 100 | 147 | let i = try SkyEngine.illumination(body, at: date) |
| 101 | 148 | return PlacedBody( |
Packages/MoonriseKit/Sources/SkyRenderer/SkyView.swift +87 −6
| @@ -67,14 +67,32 @@ public struct SkyStyle: Sendable { | ||
| 67 | 67 | return s |
| 68 | 68 | }() |
| 69 | 69 | |
| 70 | /// Star tint from B−V colour index, kept pale. | |
| 71 | func starColor(_ bv: Double) -> Color { | |
| 70 | /// Star colour for a tint from 0 (blue-white) to 1 (orange), kept pale. | |
| 71 | func starColor(tint t: Double) -> Color { | |
| 72 | 72 | if let monochromeStars { return monochromeStars } |
| 73 | let t = min(max((bv + 0.3) / 2.0, 0), 1) | |
| 74 | 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. | |
| 78 | struct 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 | 96 | /// Something to draw attention to, e.g. the current stop of a guided tour. |
| 79 | 97 | public enum SkyHighlight: Sendable, Equatable { |
| 80 | 98 | /// IAU abbreviation. |
| @@ -93,14 +111,16 @@ public struct SkyView: View { | ||
| 93 | 111 | /// Figures to draw instead of the IAU constellation lines. |
| 94 | 112 | public var culture: SkyCulture? |
| 95 | 113 | public var highlight: SkyHighlight? |
| 114 | public var showDeepSky: Bool | |
| 96 | 115 | /// Called after each draw with the time the draw took. |
| 97 | 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 | 119 | self.scene = scene |
| 101 | 120 | self.style = style |
| 102 | 121 | self.culture = culture |
| 103 | 122 | self.highlight = highlight |
| 123 | self.showDeepSky = showDeepSky | |
| 104 | 124 | self.centerAzimuth = centerAzimuth |
| 105 | 125 | self.centerAltitude = centerAltitude |
| 106 | 126 | self.fieldOfView = fieldOfView |
| @@ -114,6 +134,7 @@ public struct SkyView: View { | ||
| 114 | 134 | context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(style.background)) |
| 115 | 135 | drawConstellations(&context, p) |
| 116 | 136 | drawStars(&context, p) |
| 137 | if showDeepSky { drawDeepSky(&context, p) } | |
| 117 | 138 | drawBodies(&context, p) |
| 118 | 139 | if let highlight { drawHighlight(highlight, &context, p) } |
| 119 | 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 | 218 | func drawStars(_ context: inout GraphicsContext, _ p: Projection) { |
| 219 | var bins = [Path](repeating: Path(), count: StarBin.count) | |
| 197 | 220 | for s in scene.stars where s.position.altitude > 0 { |
| 198 | 221 | guard let pt = p.point(s.position) else { continue } |
| 199 | 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)) | |
| 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))) | |
| 223 | bins[StarBin(magnitude: s.star.magnitude, colorIndex: s.star.colorIndex).index] | |
| 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 | 286 | func drawBodies(_ context: inout GraphicsContext, _ p: Projection) { |
Packages/MoonriseKit/Tests/LibraryTests/LibraryTests.swift +6
| @@ -42,6 +42,12 @@ func linksPointAtThingsTheAppKnows(_ p: LibraryPage) { | ||
| 42 | 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 | 51 | @Test func searchFindsByTitleBeforeEssay() { |
| 46 | 52 | guard let first = Library.pages.first else { return } |
| 47 | 53 | #expect(Library.search(first.title).first?.id == first.id) |
Packages/MoonriseKit/Tests/SkyRendererTests/DeepSkyTests.swift added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | import Foundation | |
| 2 | import SkyEngine | |
| 3 | import SwiftUI | |
| 4 | import 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 | 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 | 71 | @MainActor |
| 60 | 72 | @Test func nightStyleRenders() throws { |
| 61 | 73 | let scene = try SkyScene(date: evening, observer: chicago) |
THIRD_PARTY.org +1
| @@ -6,6 +6,7 @@ | ||
| 6 | 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 | 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 | 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 | 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 | 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 | 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 | ||
| 4 | Usage: make-messier.py | |
| 5 | ||
| 6 | Rows with a Messier number in NGC.csv and addendum.csv (M40, M45). Coordinates are J2000 degrees, | |
| 7 | axes arcminutes, position angle degrees east of north; magnitude is V, else B. Each object carries the | |
| 8 | id of its Library page (link.messier in pages/deep-sky-*.json). | |
| 9 | ||
| 10 | M102: OpenNGC lists it as a duplicate observation of M101 (addendum type "Dup"), so it is left out | |
| 11 | and the file has 109 objects. The Library page for M102 keeps its own account (probably NGC 5866). | |
| 12 | """ | |
| 13 | import csv | |
| 14 | import io | |
| 15 | import json | |
| 16 | import urllib.request | |
| 17 | from pathlib import Path | |
| 18 | ||
| 19 | ROOT = Path(__file__).resolve().parent.parent | |
| 20 | RES = ROOT / "Packages/MoonriseKit/Sources/SkyRenderer/Resources" | |
| 21 | PAGES = ROOT / "Packages/MoonriseKit/Sources/Library/Resources/pages" | |
| 22 | VERSION = "v20260501" | |
| 23 | BASE = f"https://raw.githubusercontent.com/mattiaverga/OpenNGC/{VERSION}/database_files" | |
| 24 | ||
| 25 | ||
| 26 | def fetch(url): | |
| 27 | with urllib.request.urlopen(url, timeout=60) as r: | |
| 28 | return r.read().decode("utf-8") | |
| 29 | ||
| 30 | ||
| 31 | def 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 | ||
| 38 | def number(s): | |
| 39 | return float(s) if s.strip() else None | |
| 40 | ||
| 41 | ||
| 42 | def 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 | ||
| 72 | if __name__ == "__main__": | |
| 73 | main() | |