Packages/MoonriseKit/Sources/SkyRenderer/SkyView.swift
284 lines · 13666 bytes
17 symbols in this file
1import SkyEngine
2import SwiftUI
3
4/// Stereographic projection of the sky centred on a viewing direction.
5public struct Projection: Sendable {
6 public var centerAzimuth: Double
7 public var centerAltitude: Double
8 /// Horizontal field of view in degrees.
9 public var fieldOfView: Double
10 public var size: CGSize
11
12 public init(centerAzimuth: Double, centerAltitude: Double, fieldOfView: Double, size: CGSize) {
13 self.centerAzimuth = centerAzimuth
14 self.centerAltitude = centerAltitude
15 self.fieldOfView = fieldOfView
16 self.size = size
17 }
18
19 /// Screen point, or nil when the direction is behind the viewer.
20 public func point(_ h: Horizontal) -> CGPoint? {
21 let r = Double.pi / 180
22 let alt = h.altitude * r, alt0 = centerAltitude * r
23 let dAz = (h.azimuth - centerAzimuth) * r
24 let cosC = sin(alt0) * sin(alt) + cos(alt0) * cos(alt) * cos(dAz)
25 guard cosC > -0.2 else { return nil }
26 let k = 2 / (1 + cosC)
27 let x = k * cos(alt) * sin(dAz)
28 let y = k * (cos(alt0) * sin(alt) - sin(alt0) * cos(alt) * cos(dAz))
29 let scale = Double(size.width) / (4 * tan(fieldOfView * r / 4))
30 return CGPoint(x: Double(size.width) / 2 + x * scale, y: Double(size.height) / 2 - y * scale)
31 }
32}
33
34public struct SkyStyle: Sendable {
35 public var background = Color(red: 0.043, green: 0.078, blue: 0.141)
36 public var ground = Color(red: 0.02, green: 0.035, blue: 0.06)
37 public var line = Color(red: 0.30, green: 0.36, blue: 0.45)
38 public var label = Color(red: 0.95, green: 0.95, blue: 0.93)
39 public var muted = Color(red: 0.60, green: 0.64, blue: 0.70)
40 public var moonLight = Color(red: 0.93, green: 0.91, blue: 0.85)
41 public var moonDark = Color(red: 0.16, green: 0.20, blue: 0.27)
42 public var planet = Color(red: 0.95, green: 0.84, blue: 0.60)
43 /// Rings and lines for whatever a tour is pointing at.
44 public var highlight = Color(red: 1.0, green: 0.78, blue: 0.35)
45 /// Draw stars in a single red, for dark-adapted eyes.
46 public var monochromeStars: Color?
47
48 public init() {}
49
50 /// Night-mode red: 6.2:1 against black, 5.1:1 at 90% opacity (WCAG AA needs 4.5:1).
51 public static let nightRed = Color(red: 1.0, green: 0.27, blue: 0.2)
52
53 /// Red on black, to keep night vision.
54 public static let night: SkyStyle = {
55 var s = SkyStyle()
56 let red = SkyStyle.nightRed
57 s.background = .black
58 s.ground = Color(red: 0.06, green: 0, blue: 0)
59 s.line = red.opacity(0.35)
60 s.label = red
61 s.muted = red.opacity(0.9)
62 s.moonLight = red
63 s.highlight = red
64 s.moonDark = Color(red: 0.12, green: 0, blue: 0)
65 s.planet = red
66 s.monochromeStars = red
67 return s
68 }()
69
70 /// Star tint from B−V colour index, kept pale.
71 func starColor(_ bv: Double) -> Color {
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)
75 }
76}
77
78/// Something to draw attention to, e.g. the current stop of a guided tour.
79public enum SkyHighlight: Sendable, Equatable {
80 /// IAU abbreviation.
81 case constellation(String)
82 /// Bright Star Catalogue HR number.
83 case star(Int)
84 case body(Body)
85}
86
87public struct SkyView: View {
88 public var scene: SkyScene
89 public var centerAzimuth: Double
90 public var centerAltitude: Double
91 public var fieldOfView: Double
92 public var style: SkyStyle
93 /// Figures to draw instead of the IAU constellation lines.
94 public var culture: SkyCulture?
95 public var highlight: SkyHighlight?
96 /// Called after each draw with the time the draw took.
97 public var onDraw: (@Sendable (Duration) -> Void)?
98
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) {
100 self.scene = scene
101 self.style = style
102 self.culture = culture
103 self.highlight = highlight
104 self.centerAzimuth = centerAzimuth
105 self.centerAltitude = centerAltitude
106 self.fieldOfView = fieldOfView
107 self.onDraw = onDraw
108 }
109
110 public var body: some View {
111 Canvas { context, size in
112 let start = ContinuousClock.now
113 let p = Projection(centerAzimuth: centerAzimuth, centerAltitude: centerAltitude, fieldOfView: fieldOfView, size: size)
114 context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(style.background))
115 drawConstellations(&context, p)
116 drawStars(&context, p)
117 drawBodies(&context, p)
118 if let highlight { drawHighlight(highlight, &context, p) }
119 drawHorizon(&context, p, size: size)
120 onDraw?(ContinuousClock.now - start)
121 }
122 }
123
124 func drawConstellations(_ context: inout GraphicsContext, _ p: Projection) {
125 if let culture {
126 drawCulture(culture, &context, p)
127 return
128 }
129 var path = Path()
130 for c in Catalog.constellations {
131 for line in c.lines {
132 for (a, b) in zip(line, line.dropFirst()) {
133 guard let ha = scene.starsByHR[a], let hb = scene.starsByHR[b],
134 ha.altitude > -5 || hb.altitude > -5,
135 let pa = p.point(ha), let pb = p.point(hb) else { continue }
136 path.move(to: pa)
137 path.addLine(to: pb)
138 }
139 }
140 }
141 context.stroke(path, with: .color(style.line), lineWidth: 0.6)
142 }
143
144 /// A culture's figures, named at the middle of their visible stars once zoomed in
145 /// (closer for cultures with many small figures).
146 func drawCulture(_ culture: SkyCulture, _ context: inout GraphicsContext, _ p: Projection) {
147 var path = Path()
148 let labelBelow = culture.constellations.count > 60 ? 60.0 : 110.0
149 for figure in culture.constellations {
150 var points: [CGPoint] = []
151 for line in figure.lines {
152 for (a, b) in zip(line, line.dropFirst()) {
153 guard let ha = scene.starsByHR[a], let hb = scene.starsByHR[b],
154 ha.altitude > -5 || hb.altitude > -5,
155 let pa = p.point(ha), let pb = p.point(hb) else { continue }
156 path.move(to: pa)
157 path.addLine(to: pb)
158 points += [pa, pb]
159 }
160 }
161 if fieldOfView <= labelBelow, points.count > 1 {
162 let c = CGPoint(x: points.map(\.x).reduce(0, +) / Double(points.count), y: points.map(\.y).reduce(0, +) / Double(points.count))
163 context.draw(Text(figure.name).font(.caption2).foregroundStyle(style.muted), at: c)
164 }
165 }
166 context.stroke(path, with: .color(style.line), lineWidth: 0.6)
167 }
168
169 /// A constellation's IAU lines drawn bright and thick; a star or body ringed.
170 func drawHighlight(_ h: SkyHighlight, _ context: inout GraphicsContext, _ p: Projection) {
171 func ring(_ position: Horizontal?, radius: Double) {
172 guard let position, let pt = p.point(position) else { return }
173 context.stroke(Path(ellipseIn: CGRect(x: pt.x - radius, y: pt.y - radius, width: 2 * radius, height: 2 * radius)),
174 with: .color(style.highlight), lineWidth: 2)
175 }
176 switch h {
177 case .star(let hr):
178 ring(scene.starsByHR[hr], radius: 14)
179 case .body(let body):
180 ring(scene.bodies.first { $0.body == body }?.position, radius: 18)
181 case .constellation(let id):
182 guard let figure = Catalog.constellations.first(where: { $0.id == id }) else { return }
183 var path = Path()
184 for line in figure.lines {
185 for (a, b) in zip(line, line.dropFirst()) {
186 guard let ha = scene.starsByHR[a], let hb = scene.starsByHR[b],
187 let pa = p.point(ha), let pb = p.point(hb) else { continue }
188 path.move(to: pa)
189 path.addLine(to: pb)
190 }
191 }
192 context.stroke(path, with: .color(style.highlight.opacity(0.9)), style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
193 }
194 }
195
196 func drawStars(_ context: inout GraphicsContext, _ p: Projection) {
197 for s in scene.stars where s.position.altitude > 0 {
198 guard let pt = p.point(s.position) else { continue }
199 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)))
202 }
203 }
204
205 func drawBodies(_ context: inout GraphicsContext, _ p: Projection) {
206 // Naked-eye bodies only; Neptune (about mag 7.8) is left out.
207 for b in scene.bodies where b.position.altitude > 0 && b.magnitude <= 6 {
208 guard let pt = p.point(b.position) else { continue }
209 if b.body == .moon {
210 drawMoon(&context, at: pt, radius: 14, fraction: b.phaseFraction, waxing: scene.moonPhase < 180)
211 label(&context, "Moon \(Int((b.phaseFraction * 100).rounded()))%", at: CGPoint(x: pt.x - 20, y: pt.y), anchor: .trailing)
212 } else {
213 let r = max(2.0, 3.6 - 0.35 * b.magnitude)
214 context.fill(Path(ellipseIn: CGRect(x: pt.x - r, y: pt.y - r, width: 2 * r, height: 2 * r)), with: .color(style.planet))
215 // Label to the right, or to the left when it would run off the edge (large text sizes).
216 let name = b.body.rawValue.capitalized
217 let width = context.resolve(Text(name).font(.caption2)).measure(in: p.size).width
218 let side: Double = pt.x + r + 18 + width > p.size.width ? -1 : 1
219 var tick = Path()
220 tick.move(to: CGPoint(x: pt.x + side * (r + 3), y: pt.y))
221 tick.addLine(to: CGPoint(x: pt.x + side * (r + 15), y: pt.y))
222 context.stroke(tick, with: .color(style.muted), lineWidth: 0.6)
223 label(&context, name, at: CGPoint(x: pt.x + side * (r + 18), y: pt.y), anchor: side > 0 ? .leading : .trailing)
224 }
225 }
226 }
227
228 func drawMoon(_ context: inout GraphicsContext, at c: CGPoint, radius r: Double, fraction: Double, waxing: Bool) {
229 let disc = CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)
230 context.fill(Path(ellipseIn: disc), with: .color(style.moonDark))
231 context.fill(Path.moonLit(center: c, radius: r, fraction: fraction, waxing: waxing), with: .color(style.moonLight))
232 }
233
234 func drawHorizon(_ context: inout GraphicsContext, _ p: Projection, size: CGSize) {
235 var edge: [CGPoint] = []
236 var az = centerAzimuth - fieldOfView
237 while az <= centerAzimuth + fieldOfView {
238 if let pt = p.point(Horizontal(altitude: 0, azimuth: az)) { edge.append(pt) }
239 az += 1
240 }
241 guard let first = edge.first, let last = edge.last else { return }
242 var ground = Path()
243 ground.move(to: first)
244 for pt in edge.dropFirst() { ground.addLine(to: pt) }
245 ground.addLine(to: CGPoint(x: last.x, y: size.height + 10))
246 ground.addLine(to: CGPoint(x: first.x, y: size.height + 10))
247 ground.closeSubpath()
248 context.fill(ground, with: .color(style.ground))
249 for (name, az) in [("N", 0.0), ("NE", 45.0), ("E", 90.0), ("SE", 135.0), ("S", 180.0), ("SW", 225.0), ("W", 270.0), ("NW", 315.0)] {
250 guard let pt = p.point(Horizontal(altitude: 0, azimuth: az)), pt.x > 0, pt.x < size.width else { continue }
251 label(&context, name, at: CGPoint(x: pt.x, y: pt.y + 18), anchor: .center, color: style.muted)
252 }
253 }
254
255 func label(_ context: inout GraphicsContext, _ text: String, at point: CGPoint, anchor: UnitPoint, color: Color? = nil) {
256 let t = Text(text).font(.caption2).foregroundStyle(color ?? style.label)
257 context.draw(t, at: point, anchor: anchor)
258 }
259}
260
261extension Path {
262 /// The lit part of the moon's disc: a half disc plus or minus the terminator ellipse.
263 /// Waxing is lit on the right, as seen from the northern hemisphere.
264 public static func moonLit(center c: CGPoint, radius r: Double, fraction: Double, waxing: Bool) -> Path {
265 let side: Double = waxing ? 1 : -1
266 var lit = Path()
267 lit.move(to: CGPoint(x: c.x, y: c.y - r))
268 lit.addArc(center: c, radius: r, startAngle: .degrees(-90), endAngle: .degrees(90), clockwise: side < 0)
269 lit.addEllipseArc(center: c, rx: r * abs(1 - 2 * fraction), ry: r, gibbous: fraction > 0.5, side: side)
270 lit.closeSubpath()
271 return lit
272 }
273
274 /// Half of the terminator ellipse, from the moon's bottom to its top.
275 mutating func addEllipseArc(center c: CGPoint, rx: Double, ry: Double, gibbous: Bool, side: Double) {
276 // Gibbous: terminator bulges into the dark half; crescent: into the lit half.
277 let direction = gibbous ? -side : side
278 let steps = 24
279 for i in 0...steps {
280 let a = Double.pi / 2 - Double.pi * Double(i) / Double(steps)
281 addLine(to: CGPoint(x: c.x + direction * rx * cos(a), y: c.y + ry * sin(a)))
282 }
283 }
284}