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 94af22ba11

94af22ba11829f5b5aeb59f86afccf3e689d029b

parent: 3b8c40e96a

Verified · cmc

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

Sky chart: one fill per star bin instead of per star

Stars are grouped into half-magnitude by four-tint bins; each star keeps
its own radius.

Layout: unified · split

Packages/MoonriseKit/Sources/SkyRenderer/SkyView.swift +30 −5
@@ -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.
@@ -193,12 +211,19 @@ public struct SkyView: View {
193 } 211 }
194 } 212 }
195 213
214 /// Each star at its own radius; colour and opacity per `StarBin`, faintest bins first.
196 func drawStars(_ context: inout GraphicsContext, _ p: Projection) { 215 func drawStars(_ context: inout GraphicsContext, _ p: Projection) {
216 var bins = [Path](repeating: Path(), count: StarBin.count)
197 for s in scene.stars where s.position.altitude > 0 { 217 for s in scene.stars where s.position.altitude > 0 {
198 guard let pt = p.point(s.position) else { continue } 218 guard let pt = p.point(s.position) else { continue }
199 let r = max(0.3, 2.6 - 0.42 * s.star.magnitude) 219 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)) 220 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))) 221 .addEllipse(in: CGRect(x: pt.x - r, y: pt.y - r, width: 2 * r, height: 2 * r))
222 }
223 for i in bins.indices.reversed() where !bins[i].isEmpty {
224 let bin = StarBin(index: i)
225 let opacity = min(1, max(0.25, 1.15 - 0.13 * bin.magnitude))
226 context.fill(bins[i], with: .color(style.starColor(tint: bin.tint).opacity(opacity)))
202 } 227 }
203 } 228 }
204 229
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)