App/Sources/PageDrawing.swift
239 lines · 12239 bytes
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}