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

Packages/MoonriseKit/Sources/SkyRenderer/SkyView.swift

04b2b1d914788057bcd20945b876847386eb8f65
moonrise/Packages/MoonriseKit/Sources/SkyRenderer/SkyView.swift history · blame · raw

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}