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

6c4b28384d65e09486e0ad62f2ac9d10401cc80c
moonrise/Packages/MoonriseKit/Sources/SkyRenderer/SkyView.swift history · blame · raw

365 lines · 18210 bytes

  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 colour for a tint from 0 (blue-white) to 1 (orange), kept pale.
 71    func starColor(tint t: Double) -> Color {
 72        if let monochromeStars { return monochromeStars }
 73        return Color(red: 0.80 + 0.20 * t, green: 0.86 - 0.06 * t, blue: 1.0 - 0.32 * t)
 74    }
 75}
 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
 96/// Something to draw attention to, e.g. the current stop of a guided tour.
 97public enum SkyHighlight: Sendable, Equatable {
 98    /// IAU abbreviation.
 99    case constellation(String)
100    /// Bright Star Catalogue HR number.
101    case star(Int)
102    case body(Body)
103}
104
105public struct SkyView: View {
106    public var scene: SkyScene
107    public var centerAzimuth: Double
108    public var centerAltitude: Double
109    public var fieldOfView: Double
110    public var style: SkyStyle
111    /// Figures to draw instead of the IAU constellation lines.
112    public var culture: SkyCulture?
113    public var highlight: SkyHighlight?
114    public var showDeepSky: Bool
115    /// Called after each draw with the time the draw took.
116    public var onDraw: (@Sendable (Duration) -> Void)?
117
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) {
119        self.scene = scene
120        self.style = style
121        self.culture = culture
122        self.highlight = highlight
123        self.showDeepSky = showDeepSky
124        self.centerAzimuth = centerAzimuth
125        self.centerAltitude = centerAltitude
126        self.fieldOfView = fieldOfView
127        self.onDraw = onDraw
128    }
129
130    public var body: some View {
131        Canvas { context, size in
132            let start = ContinuousClock.now
133            let p = Projection(centerAzimuth: centerAzimuth, centerAltitude: centerAltitude, fieldOfView: fieldOfView, size: size)
134            context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(style.background))
135            drawConstellations(&context, p)
136            drawStars(&context, p)
137            if showDeepSky { drawDeepSky(&context, p) }
138            drawBodies(&context, p)
139            if let highlight { drawHighlight(highlight, &context, p) }
140            drawHorizon(&context, p, size: size)
141            onDraw?(ContinuousClock.now - start)
142        }
143    }
144
145    func drawConstellations(_ context: inout GraphicsContext, _ p: Projection) {
146        if let culture {
147            drawCulture(culture, &context, p)
148            return
149        }
150        var path = Path()
151        for c in Catalog.constellations {
152            for line in c.lines {
153                for (a, b) in zip(line, line.dropFirst()) {
154                    guard let ha = scene.starsByHR[a], let hb = scene.starsByHR[b],
155                          ha.altitude > -5 || hb.altitude > -5,
156                          let pa = p.point(ha), let pb = p.point(hb) else { continue }
157                    path.move(to: pa)
158                    path.addLine(to: pb)
159                }
160            }
161        }
162        context.stroke(path, with: .color(style.line), lineWidth: 0.6)
163    }
164
165    /// A culture's figures, named at the middle of their visible stars once zoomed in
166    /// (closer for cultures with many small figures).
167    func drawCulture(_ culture: SkyCulture, _ context: inout GraphicsContext, _ p: Projection) {
168        var path = Path()
169        let labelBelow = culture.constellations.count > 60 ? 60.0 : 110.0
170        for figure in culture.constellations {
171            var points: [CGPoint] = []
172            for line in figure.lines {
173                for (a, b) in zip(line, line.dropFirst()) {
174                    guard let ha = scene.starsByHR[a], let hb = scene.starsByHR[b],
175                          ha.altitude > -5 || hb.altitude > -5,
176                          let pa = p.point(ha), let pb = p.point(hb) else { continue }
177                    path.move(to: pa)
178                    path.addLine(to: pb)
179                    points += [pa, pb]
180                }
181            }
182            if fieldOfView <= labelBelow, points.count > 1 {
183                let c = CGPoint(x: points.map(\.x).reduce(0, +) / Double(points.count), y: points.map(\.y).reduce(0, +) / Double(points.count))
184                context.draw(Text(figure.name).font(.caption2).foregroundStyle(style.muted), at: c)
185            }
186        }
187        context.stroke(path, with: .color(style.line), lineWidth: 0.6)
188    }
189
190    /// A constellation's IAU lines drawn bright and thick; a star or body ringed.
191    func drawHighlight(_ h: SkyHighlight, _ context: inout GraphicsContext, _ p: Projection) {
192        func ring(_ position: Horizontal?, radius: Double) {
193            guard let position, let pt = p.point(position) else { return }
194            context.stroke(Path(ellipseIn: CGRect(x: pt.x - radius, y: pt.y - radius, width: 2 * radius, height: 2 * radius)),
195                           with: .color(style.highlight), lineWidth: 2)
196        }
197        switch h {
198        case .star(let hr):
199            ring(scene.starsByHR[hr], radius: 14)
200        case .body(let body):
201            ring(scene.bodies.first { $0.body == body }?.position, radius: 18)
202        case .constellation(let id):
203            guard let figure = Catalog.constellations.first(where: { $0.id == id }) else { return }
204            var path = Path()
205            for line in figure.lines {
206                for (a, b) in zip(line, line.dropFirst()) {
207                    guard let ha = scene.starsByHR[a], let hb = scene.starsByHR[b],
208                          let pa = p.point(ha), let pb = p.point(hb) else { continue }
209                    path.move(to: pa)
210                    path.addLine(to: pb)
211                }
212            }
213            context.stroke(path, with: .color(style.highlight.opacity(0.9)), style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
214        }
215    }
216
217    /// Each star at its own radius; colour and opacity per `StarBin`, faintest bins first.
218    func drawStars(_ context: inout GraphicsContext, _ p: Projection) {
219        var bins = [Path](repeating: Path(), count: StarBin.count)
220        for s in scene.stars where s.position.altitude > 0 {
221            guard let pt = p.point(s.position) else { continue }
222            let r = max(0.3, 2.6 - 0.42 * s.star.magnitude)
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            }
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]))
284    }
285
286    func drawBodies(_ context: inout GraphicsContext, _ p: Projection) {
287        // Naked-eye bodies only; Neptune (about mag 7.8) is left out.
288        for b in scene.bodies where b.position.altitude > 0 && b.magnitude <= 6 {
289            guard let pt = p.point(b.position) else { continue }
290            if b.body == .moon {
291                drawMoon(&context, at: pt, radius: 14, fraction: b.phaseFraction, waxing: scene.moonPhase < 180)
292                label(&context, "Moon \(Int((b.phaseFraction * 100).rounded()))%", at: CGPoint(x: pt.x - 20, y: pt.y), anchor: .trailing)
293            } else {
294                let r = max(2.0, 3.6 - 0.35 * b.magnitude)
295                context.fill(Path(ellipseIn: CGRect(x: pt.x - r, y: pt.y - r, width: 2 * r, height: 2 * r)), with: .color(style.planet))
296                // Label to the right, or to the left when it would run off the edge (large text sizes).
297                let name = b.body.rawValue.capitalized
298                let width = context.resolve(Text(name).font(.caption2)).measure(in: p.size).width
299                let side: Double = pt.x + r + 18 + width > p.size.width ? -1 : 1
300                var tick = Path()
301                tick.move(to: CGPoint(x: pt.x + side * (r + 3), y: pt.y))
302                tick.addLine(to: CGPoint(x: pt.x + side * (r + 15), y: pt.y))
303                context.stroke(tick, with: .color(style.muted), lineWidth: 0.6)
304                label(&context, name, at: CGPoint(x: pt.x + side * (r + 18), y: pt.y), anchor: side > 0 ? .leading : .trailing)
305            }
306        }
307    }
308
309    func drawMoon(_ context: inout GraphicsContext, at c: CGPoint, radius r: Double, fraction: Double, waxing: Bool) {
310        let disc = CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)
311        context.fill(Path(ellipseIn: disc), with: .color(style.moonDark))
312        context.fill(Path.moonLit(center: c, radius: r, fraction: fraction, waxing: waxing), with: .color(style.moonLight))
313    }
314
315    func drawHorizon(_ context: inout GraphicsContext, _ p: Projection, size: CGSize) {
316        var edge: [CGPoint] = []
317        var az = centerAzimuth - fieldOfView
318        while az <= centerAzimuth + fieldOfView {
319            if let pt = p.point(Horizontal(altitude: 0, azimuth: az)) { edge.append(pt) }
320            az += 1
321        }
322        guard let first = edge.first, let last = edge.last else { return }
323        var ground = Path()
324        ground.move(to: first)
325        for pt in edge.dropFirst() { ground.addLine(to: pt) }
326        ground.addLine(to: CGPoint(x: last.x, y: size.height + 10))
327        ground.addLine(to: CGPoint(x: first.x, y: size.height + 10))
328        ground.closeSubpath()
329        context.fill(ground, with: .color(style.ground))
330        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)] {
331            guard let pt = p.point(Horizontal(altitude: 0, azimuth: az)), pt.x > 0, pt.x < size.width else { continue }
332            label(&context, name, at: CGPoint(x: pt.x, y: pt.y + 18), anchor: .center, color: style.muted)
333        }
334    }
335
336    func label(_ context: inout GraphicsContext, _ text: String, at point: CGPoint, anchor: UnitPoint, color: Color? = nil) {
337        let t = Text(text).font(.caption2).foregroundStyle(color ?? style.label)
338        context.draw(t, at: point, anchor: anchor)
339    }
340}
341
342extension Path {
343    /// The lit part of the moon's disc: a half disc plus or minus the terminator ellipse.
344    /// Waxing is lit on the right, as seen from the northern hemisphere.
345    public static func moonLit(center c: CGPoint, radius r: Double, fraction: Double, waxing: Bool) -> Path {
346        let side: Double = waxing ? 1 : -1
347        var lit = Path()
348        lit.move(to: CGPoint(x: c.x, y: c.y - r))
349        lit.addArc(center: c, radius: r, startAngle: .degrees(-90), endAngle: .degrees(90), clockwise: side < 0)
350        lit.addEllipseArc(center: c, rx: r * abs(1 - 2 * fraction), ry: r, gibbous: fraction > 0.5, side: side)
351        lit.closeSubpath()
352        return lit
353    }
354
355    /// Half of the terminator ellipse, from the moon's bottom to its top.
356    mutating func addEllipseArc(center c: CGPoint, rx: Double, ry: Double, gibbous: Bool, side: Double) {
357        // Gibbous: terminator bulges into the dark half; crescent: into the lit half.
358        let direction = gibbous ? -side : side
359        let steps = 24
360        for i in 0...steps {
361            let a = Double.pi / 2 - Double.pi * Double(i) / Double(steps)
362            addLine(to: CGPoint(x: c.x + direction * rx * cos(a), y: c.y + ry * sin(a)))
363        }
364    }
365}