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

App/Sources/PageDrawing.swift

main
moonrise/App/Sources/PageDrawing.swift history · blame · raw

239 lines · 12239 bytes

6 symbols in this file
  1import Library
  2import simd
  3import SkyRenderer
  4import SwiftUI
  5
  6/// The generated drawing at the top of a Library page, built from the page's data.
  7struct PageDrawing: View {
  8    var page: LibraryPage
  9    var ink: Color
 10    var accent: Color
 11
 12    var body: some View {
 13        Group {
 14            switch page.drawing?.kind {
 15            case "planet": planet
 16            case "constellation": constellation
 17            case "star": star
 18            case "deep-sky": deepSky
 19            case "mission": mission
 20            case "exoplanet": exoplanet
 21            case "rocket": RocketLineup(highlight: page.id, ink: ink, accent: accent).frame(height: 260)
 22            default: EmptyView()
 23            }
 24        }
 25        .frame(maxWidth: .infinity)
 26    }
 27
 28    // MARK: Planets, moons, the Sun
 29
 30    /// The body to scale with Earth beside it (or the Sun with Earth as a dot).
 31    private var planet: some View {
 32        let d = page.drawing!
 33        let radius = d.radiusKm ?? 6371
 34        let fill = Color(hex: d.color) ?? ink.opacity(0.6)
 35        return Canvas { context, size in
 36            let earthKm = 6371.0
 37            let ringed = d.rings == true
 38            // Rings reach about 2.2 body radii; leave room for the Earth label underneath.
 39            let extent = ringed ? 2.25 : 1.0
 40            let maxR = min(size.height / 2 - 16, (size.width - 24) / (2 * extent))
 41            let scale = maxR / max(radius, earthKm)
 42            let r = radius * scale, er = max(earthKm * scale, 1.5)
 43            let c = CGPoint(x: size.width / 2, y: size.height / 2 - 8)
 44            if ringed {
 45                let ring = Path(ellipseIn: CGRect(x: c.x - r * 2.2, y: c.y - r * 0.45, width: r * 4.4, height: r * 0.9))
 46                context.stroke(ring, with: .color(ink.opacity(0.7)), lineWidth: 1)
 47            }
 48            context.fill(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(fill))
 49            context.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(ink.opacity(0.8)), lineWidth: 0.8)
 50            if let tilt = d.axialTiltDeg {
 51                let a = tilt * .pi / 180
 52                var axis = Path()
 53                axis.move(to: CGPoint(x: c.x - sin(a) * r * 1.25, y: c.y - cos(a) * r * 1.25))
 54                axis.addLine(to: CGPoint(x: c.x + sin(a) * r * 1.25, y: c.y + cos(a) * r * 1.25))
 55                context.stroke(axis, with: .color(accent), style: StrokeStyle(lineWidth: 0.8, dash: [3, 3]))
 56            }
 57            if page.id != "earth" {
 58                let ec = CGPoint(x: size.width - er - 6, y: size.height - er - 16)
 59                context.fill(Path(ellipseIn: CGRect(x: ec.x - er, y: ec.y - er, width: 2 * er, height: 2 * er)), with: .color(Color(red: 0.35, green: 0.6, blue: 0.95)))
 60                context.draw(Text("Earth").font(.caption2).foregroundStyle(ink.opacity(0.6)), at: CGPoint(x: size.width - 2, y: size.height - 6), anchor: .trailing)
 61            }
 62        }
 63        .frame(height: 200)
 64        .accessibilityLabel("\(page.title) drawn to scale with Earth")
 65    }
 66
 67    // MARK: Constellations
 68
 69    private var constellation: some View {
 70        ConstellationFigure(id: page.link.constellation, ink: ink)
 71            .frame(height: 240)
 72            .accessibilityLabel("Figure of \(page.title)")
 73    }
 74
 75    // MARK: Stars
 76
 77    private var star: some View {
 78        let bv = page.drawing?.colorIndex ?? 0.6
 79        let t = min(max((bv + 0.3) / 2.0, 0), 1)
 80        let color = Color(red: 0.75 + 0.25 * t, green: 0.82 - 0.1 * t, blue: 1.0 - 0.45 * t)
 81        return VStack(spacing: 8) {
 82            Circle().fill(color).frame(width: 90, height: 90)
 83            if let sp = page.drawing?.spectralType {
 84                Text("Spectral type \(sp) · B−V \(String(format: "%.2f", bv))").font(.caption2).opacity(0.6)
 85            }
 86        }
 87        .frame(height: 140)
 88    }
 89
 90    // MARK: Deep sky
 91
 92    /// The object's apparent size beside the full Moon (about 31′).
 93    private var deepSky: some View {
 94        let d = page.drawing!
 95        let size = d.sizeArcmin ?? 5
 96        return Canvas { context, canvas in
 97            let moon = 31.0
 98            let scale = (canvas.height - 30) / max(size, moon)
 99            let r = size * scale / 2, mr = moon * scale / 2
100            let c = CGPoint(x: canvas.width / 2 - mr - 10, y: canvas.height / 2 - 8)
101            let m = CGPoint(x: canvas.width / 2 + r + 10, y: canvas.height / 2 - 8)
102            let rect = CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)
103            switch d.type {
104            case "galaxy":
105                context.fill(Path(ellipseIn: rect.insetBy(dx: 0, dy: r * 0.6)), with: .color(ink.opacity(0.5)))
106            case "planetary":
107                context.stroke(Path(ellipseIn: rect), with: .color(ink.opacity(0.8)), lineWidth: max(1, r * 0.25))
108            case "globular", "open":
109                context.fill(Path(ellipseIn: rect), with: .color(ink.opacity(d.type == "globular" ? 0.45 : 0.15)))
110            default:
111                context.fill(Path(ellipseIn: rect), with: .color(ink.opacity(0.3)))
112            }
113            context.stroke(Path(ellipseIn: CGRect(x: m.x - mr, y: m.y - mr, width: 2 * mr, height: 2 * mr)), with: .color(ink.opacity(0.5)), style: StrokeStyle(lineWidth: 0.8, dash: [3, 3]))
114            context.draw(Text("\(Int(size.rounded()))′").font(.caption2).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: c.x, y: canvas.height - 10))
115            context.draw(Text("Full Moon").font(.caption2).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: m.x, y: canvas.height - 10))
116        }
117        .frame(height: 170)
118        .accessibilityLabel("\(page.title) apparent size compared with the full Moon")
119    }
120
121    // MARK: Exoplanets
122
123    /// The planet beside Earth and Jupiter to scale, and its orbit beside Mercury's and Earth's.
124    private var exoplanet: some View {
125        let d = page.drawing!
126        let star = starColor(d.starTempK)
127        return HStack(alignment: .bottom, spacing: 24) {
128            if let r = d.radiusEarth {
129                Canvas { context, size in
130                    let maxR = max(r, 11.2)
131                    let scale = (size.height - 22) / (2 * maxR)
132                    let bodies: [(String, Double, Color)] = [("Earth", 1, Color(red: 0.35, green: 0.6, blue: 0.95)), (page.title, r, ink), ("Jupiter", 11.2, Color(red: 0.85, green: 0.72, blue: 0.55))]
133                    var x = 8.0
134                    for (name, radius, color) in bodies.sorted(by: { $0.1 < $1.1 }) {
135                        let rr = max(radius * scale, 1.5)
136                        let c = CGPoint(x: x + rr, y: size.height - 18 - rr)
137                        context.fill(Path(ellipseIn: CGRect(x: c.x - rr, y: c.y - rr, width: 2 * rr, height: 2 * rr)), with: .color(name == page.title ? accent : color))
138                        context.draw(Text(name).font(.caption2).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: c.x, y: size.height - 6))
139                        x += 2 * rr + 34
140                    }
141                }
142                .frame(height: 140)
143                .accessibilityLabel("\(page.title), \(String(format: "%.1f", r)) times Earth's radius")
144            }
145            if let a = d.orbitAU {
146                Canvas { context, size in
147                    let c = CGPoint(x: size.width / 2, y: size.height / 2 - 6)
148                    let outer = max(a, 1)
149                    let scale = (min(size.width, size.height) / 2 - 14) / outer
150                    for (name, au) in [("Mercury", 0.387), ("Earth", 1.0)] where au <= outer {
151                        let r = au * scale
152                        context.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(ink.opacity(0.3)), style: StrokeStyle(lineWidth: 0.6, dash: [2, 3]))
153                        context.draw(Text(name).font(.caption2).foregroundStyle(ink.opacity(0.6)), at: CGPoint(x: c.x, y: c.y - r - 7))
154                    }
155                    let r = a * scale
156                    context.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(accent), lineWidth: 1)
157                    context.fill(Path(ellipseIn: CGRect(x: c.x - 4, y: c.y - 4, width: 8, height: 8)), with: .color(star))
158                    context.draw(Text(String(format: "%.3g AU", a)).font(.caption2).foregroundStyle(ink), at: CGPoint(x: c.x, y: size.height - 6))
159                }
160                .frame(width: 150, height: 150)
161                .accessibilityLabel("Orbit \(String(format: "%.3g", a)) astronomical units from its star")
162            }
163        }
164    }
165
166    private func starColor(_ t: Double?) -> Color {
167        guard let t else { return ink }
168        if t < 3700 { return Color(red: 1, green: 0.6, blue: 0.4) }
169        if t < 5200 { return Color(red: 1, green: 0.8, blue: 0.55) }
170        if t < 6000 { return Color(red: 1, green: 0.95, blue: 0.8) }
171        return Color(red: 0.8, green: 0.88, blue: 1)
172    }
173
174    // MARK: Missions
175
176    private var mission: some View {
177        let d = page.drawing!
178        return HStack(spacing: 10) {
179            Text((d.status ?? "").uppercased())
180                .font(.caption2.weight(.medium)).tracking(0.6)
181                .padding(.horizontal, 6).padding(.vertical, 2)
182                .foregroundStyle(d.status == "active" ? Color.black : ink)
183                .background(d.status == "active" ? ink : ink.opacity(0.15), in: .rect(cornerRadius: 2))
184            if let launch = d.launch { Text("Launched \(launch)").font(.footnote.monospacedDigit()) }
185            Spacer()
186        }
187    }
188}
189
190/// A constellation's IAU figure, drawn to fit the view (gnomonic projection about its centre).
191struct ConstellationFigure: View {
192    var id: String?
193    var ink: Color
194
195    var body: some View {
196        let lines = Catalog.constellations.first { $0.id == id }?.lines ?? []
197        let stars = Set(lines.joined()).compactMap { Catalog.starsByHR[$0] }
198        Canvas { context, size in
199            guard !stars.isEmpty else { return }
200            // Gnomonic projection about the figure's centre.
201            func unit(_ s: Star) -> SIMD3<Double> {
202                let ra = s.ra * .pi / 180, dec = s.dec * .pi / 180
203                return SIMD3(cos(dec) * cos(ra), cos(dec) * sin(ra), sin(dec))
204            }
205            var c = stars.map(unit).reduce(SIMD3<Double>(repeating: 0), +)
206            c /= (c * c).sum().squareRoot()
207            let e0 = SIMD3(-c.y, c.x, 0)
208            let east = simd_length(e0) < 1e-6 ? SIMD3<Double>(0, 1, 0) : simd_normalize(e0)
209            let north = SIMD3(c.y * east.z - c.z * east.y, c.z * east.x - c.x * east.z, c.x * east.y - c.y * east.x)
210            func plane(_ s: Star) -> SIMD2<Double> {
211                let v = unit(s), k = (v * c).sum()
212                // East to the left, as on the sky.
213                return SIMD2(-(v * east).sum() / k, (v * north).sum() / k)
214            }
215            let pts = Dictionary(uniqueKeysWithValues: stars.map { ($0.hr, plane($0)) })
216            let xs = pts.values.map(\.x), ys = pts.values.map(\.y)
217            let span = max(xs.max()! - xs.min()!, ys.max()! - ys.min()!, 1e-6)
218            let scale = (min(size.width, size.height) - 24) / span
219            let mid = SIMD2((xs.max()! + xs.min()!) / 2, (ys.max()! + ys.min()!) / 2)
220            func point(_ hr: Int) -> CGPoint? {
221                pts[hr].map { CGPoint(x: size.width / 2 + ($0.x - mid.x) * scale, y: size.height / 2 - ($0.y - mid.y) * scale) }
222            }
223            var path = Path()
224            for line in lines {
225                for (a, b) in zip(line, line.dropFirst()) {
226                    guard let pa = point(a), let pb = point(b) else { continue }
227                    path.move(to: pa)
228                    path.addLine(to: pb)
229                }
230            }
231            context.stroke(path, with: .color(ink.opacity(0.45)), lineWidth: 0.8)
232            for s in stars {
233                guard let p = point(s.hr) else { continue }
234                let r = max(1.2, 4.2 - 0.6 * s.magnitude)
235                context.fill(Path(ellipseIn: CGRect(x: p.x - r, y: p.y - r, width: 2 * r, height: 2 * r)), with: .color(ink))
236            }
237        }
238    }
239}