v2: Scale of the universe !32
5 files changed, +186 −1
Layout: unified · split
App/Sources/LibraryScreen.swift +2
| @@ -15,6 +15,8 @@ struct LibraryScreen: View { | |||
| 15 | Section { | 15 | Section { |
| 16 | NavigationLink("Timeline of exploration") { ExplorationTimeline() } | 16 | NavigationLink("Timeline of exploration") { ExplorationTimeline() } |
| 17 | .listRowBackground(pageBackground) | 17 | .listRowBackground(pageBackground) |
| 18 | NavigationLink("Scale of the universe") { ScaleScreen(ink: ink, accent: accent) } | ||
| 19 | .listRowBackground(pageBackground) | ||
| 18 | if !Library.pages(in: .rockets).isEmpty { | 20 | if !Library.pages(in: .rockets).isEmpty { |
| 19 | NavigationLink("Rockets to scale") { | 21 | NavigationLink("Rockets to scale") { |
| 20 | ScrollView { RocketLineup(highlight: nil, ink: ink, accent: accent).frame(height: 420).padding(.vertical, 20) } | 22 | ScrollView { RocketLineup(highlight: nil, ink: ink, accent: accent).frame(height: 420).padding(.vertical, 20) } |
App/Sources/ScaleScreen.swift added +87
| @@ -0,0 +1,87 @@ | |||
| 1 | import Library | ||
| 2 | import SwiftUI | ||
| 3 | |||
| 4 | /// Bodies, stars, nebulae and galaxies at true relative size. Zoom with the slider or a pinch; | ||
| 5 | /// objects between 1/100 of the shown width and the full width are drawn side by side. | ||
| 6 | struct ScaleScreen: View { | ||
| 7 | var ink: Color | ||
| 8 | var accent: Color | ||
| 9 | /// log10 of the width shown, in metres. | ||
| 10 | @State private var exponent = 7.4 | ||
| 11 | @State private var pinchStart: Double? | ||
| 12 | @State private var selected: LibraryPage? | ||
| 13 | |||
| 14 | private let range = 2.5...27.0 | ||
| 15 | |||
| 16 | private var width: Double { pow(10, exponent) } | ||
| 17 | |||
| 18 | /// The width to two significant figures, for the label. | ||
| 19 | private var roundedWidth: Double { | ||
| 20 | let p = pow(10, floor(exponent) - 1) | ||
| 21 | return (width / p).rounded() * p | ||
| 22 | } | ||
| 23 | |||
| 24 | private var shown: [ScaleObject] { | ||
| 25 | Library.scaleObjects.filter { $0.meters <= width && $0.meters >= width / 100 } | ||
| 26 | } | ||
| 27 | |||
| 28 | var body: some View { | ||
| 29 | VStack(alignment: .leading, spacing: 16) { | ||
| 30 | Text("Width shown: \(Library.formatSize(roundedWidth))").font(.subheadline.monospacedDigit()).dimmed(0.8) | ||
| 31 | GeometryReader { geo in | ||
| 32 | let side = geo.size.width | ||
| 33 | ScrollView(.horizontal, showsIndicators: false) { | ||
| 34 | HStack(alignment: .bottom, spacing: 16) { | ||
| 35 | if shown.isEmpty { | ||
| 36 | Text("Nothing in the Library at this scale.").font(.footnote).dimmed(0.6) | ||
| 37 | } | ||
| 38 | ForEach(shown) { o in | ||
| 39 | let d = max(o.meters / width * side, 2) | ||
| 40 | Button { | ||
| 41 | selected = o.page.flatMap { Library.page(id: $0) } | ||
| 42 | } label: { | ||
| 43 | VStack(spacing: 6) { | ||
| 44 | Circle().fill(o.page == nil ? ink.opacity(0.18) : ink.opacity(0.55)) | ||
| 45 | .overlay(Circle().stroke(ink.opacity(0.5), lineWidth: 0.5)) | ||
| 46 | .frame(width: d, height: d) | ||
| 47 | Text(o.name).font(.caption2).lineLimit(1) | ||
| 48 | Text(Library.formatSize(o.meters)).font(.caption2.monospacedDigit()).dimmed(0.6) | ||
| 49 | } | ||
| 50 | .frame(minWidth: 64) | ||
| 51 | } | ||
| 52 | .buttonStyle(.plain) | ||
| 53 | .disabled(o.page == nil) | ||
| 54 | .accessibilityLabel("\(o.name), \(Library.formatSize(o.meters))") | ||
| 55 | } | ||
| 56 | } | ||
| 57 | .frame(minHeight: geo.size.height, alignment: .bottom) | ||
| 58 | .padding(.horizontal, 4) | ||
| 59 | } | ||
| 60 | .defaultScrollAnchor(.trailing) | ||
| 61 | .scrollClipDisabled() | ||
| 62 | } | ||
| 63 | .frame(maxHeight: .infinity) | ||
| 64 | .gesture(MagnifyGesture() | ||
| 65 | .onChanged { g in | ||
| 66 | if pinchStart == nil { pinchStart = exponent } | ||
| 67 | exponent = min(max(pinchStart! - log10(g.magnification), range.lowerBound), range.upperBound) | ||
| 68 | } | ||
| 69 | .onEnded { _ in pinchStart = nil }) | ||
| 70 | Slider(value: $exponent, in: range) { Text("Width shown") } minimumValueLabel: { | ||
| 71 | Image(systemName: "plus.magnifyingglass") | ||
| 72 | } maximumValueLabel: { | ||
| 73 | Image(systemName: "minus.magnifyingglass") | ||
| 74 | } | ||
| 75 | .accessibilityValue(Library.formatSize(roundedWidth)) | ||
| 76 | Text("The slider spans 300 m to the observable universe; each tenth of it is a factor of ten. Pinch to zoom. Objects without a Library page are drawn faint.") | ||
| 77 | .font(.caption2).dimmed(0.5) | ||
| 78 | } | ||
| 79 | .padding(20) | ||
| 80 | .foregroundStyle(ink) | ||
| 81 | .tint(accent) | ||
| 82 | .background(Color.black.ignoresSafeArea()) | ||
| 83 | .navigationTitle("Scale of the universe") | ||
| 84 | .navigationBarTitleDisplayMode(.inline) | ||
| 85 | .sheet(item: $selected) { page in NavigationStack { LibraryPageView(page: page) } } | ||
| 86 | } | ||
| 87 | } | ||
PLAN.org +1 −1
| @@ -401,7 +401,7 @@ Sky, System, Sun, Launches, Library core (above). | |||
| 401 | - Observing log stored as plain text or Org. | 401 | - Observing log stored as plain text or Org. |
| 402 | - Mars: latest rover raw images, local time at each rover. | 402 | - Mars: latest rover raw images, local time at each rover. |
| 403 | - Library: exoplanets, rockets to scale, exploration timeline, scale | 403 | - Library: exoplanets, rockets to scale, exploration timeline, scale |
| 404 | view. | 404 | view. Done. |
| 405 | - iPad, Mac, Apple Watch complications. | 405 | - iPad, Mac, Apple Watch complications. |
| 406 | 406 | ||
| 407 | ** v3: across time and cultures | 407 | ** v3: across time and cultures |
Packages/MoonriseKit/Sources/Library/Scale.swift added +77
| @@ -0,0 +1,77 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// Something with a size, for the scale view: a body, star, nebula, galaxy or orbit. | ||
| 4 | public struct ScaleObject: Sendable, Identifiable, Hashable { | ||
| 5 | public var name: String | ||
| 6 | /// Diameter, or end-to-end length, in metres. | ||
| 7 | public var meters: Double | ||
| 8 | /// Library page to open, if there is one. | ||
| 9 | public var page: String? | ||
| 10 | public var id: String { name } | ||
| 11 | } | ||
| 12 | |||
| 13 | extension Library { | ||
| 14 | static let lightYear = 9.4607e15 | ||
| 15 | static let au = 1.495978707e11 | ||
| 16 | |||
| 17 | /// Everything in the scale view, smallest first. Sizes come from the Library pages where they give one. | ||
| 18 | public static let scaleObjects: [ScaleObject] = { | ||
| 19 | var out: [ScaleObject] = [ScaleObject(name: "ISS", meters: 109, page: "international-space-station")] | ||
| 20 | |||
| 21 | // Sun, planets, dwarf planets and moons: the drawing's mean radius. | ||
| 22 | for section in [LibrarySection.sun, .planets, .dwarfPlanets, .moons] { | ||
| 23 | for p in pages(in: section) { | ||
| 24 | if let r = p.drawing?.radiusKm { out.append(ScaleObject(name: p.title, meters: 2 * r * 1000, page: p.id)) } | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | // Stars whose page gives a radius as "N × Sun". | ||
| 29 | for p in pages(in: .stars) { | ||
| 30 | guard let fact = p.facts.first(where: { $0.label == "Radius" }), let r = solarRadii(fact.value) else { continue } | ||
| 31 | out.append(ScaleObject(name: p.title, meters: 2 * r * 6.957e8, page: p.id)) | ||
| 32 | } | ||
| 33 | |||
| 34 | // Deep-sky objects: the page's distance times its apparent size (major axis). | ||
| 35 | let deepSky: [(String, Double, Double)] = [ // id, light-years, arcminutes | ||
| 36 | ("m57", 2_000, 1.4), ("m1", 6_500, 6), ("m45", 445, 110), ("m42", 1_500, 85), | ||
| 37 | ("m13", 25_000, 20), ("m33", 3_000_000, 73), ("m87", 54_000_000, 7), ("m31", 2_500_000, 178), | ||
| 38 | ] | ||
| 39 | for (id, ly, arcmin) in deepSky { | ||
| 40 | guard let p = page(id: id) else { continue } | ||
| 41 | out.append(ScaleObject(name: p.title, meters: ly * lightYear * arcmin / 60 * .pi / 180, page: id)) | ||
| 42 | } | ||
| 43 | |||
| 44 | out += [ | ||
| 45 | ScaleObject(name: "Moon's orbit", meters: 2 * 384_400e3), | ||
| 46 | ScaleObject(name: "Earth's orbit", meters: 2 * au), | ||
| 47 | ScaleObject(name: "Neptune's orbit", meters: 2 * 30.07 * au), | ||
| 48 | // Voyager 1 crossed the heliopause at about 121 AU. | ||
| 49 | ScaleObject(name: "Heliosphere", meters: 2 * 121 * au), | ||
| 50 | ScaleObject(name: "Milky Way", meters: 100_000 * lightYear), | ||
| 51 | ScaleObject(name: "Local Group", meters: 10e6 * lightYear), | ||
| 52 | // Tully et al. 2014. | ||
| 53 | ScaleObject(name: "Laniakea Supercluster", meters: 520e6 * lightYear), | ||
| 54 | ScaleObject(name: "Observable universe", meters: 93e9 * lightYear), | ||
| 55 | ] | ||
| 56 | return out.sorted { $0.meters < $1.meters } | ||
| 57 | }() | ||
| 58 | |||
| 59 | /// 25.4 from "25.4 ± 0.2 × Sun". | ||
| 60 | static func solarRadii(_ s: String) -> Double? { | ||
| 61 | guard s.contains("× Sun"), let first = s.split(separator: " ").first else { return nil } | ||
| 62 | return Double(first.replacingOccurrences(of: ",", with: "")) | ||
| 63 | } | ||
| 64 | |||
| 65 | /// "109 m", "12,742 km", "60.1 AU", "11 light-years", "2.5 million light-years". | ||
| 66 | public static func formatSize(_ meters: Double) -> String { | ||
| 67 | func n(_ v: Double) -> String { v.formatted(.number.precision(.significantDigits(1...(v < 10 ? 2 : 3)))) } | ||
| 68 | switch meters { | ||
| 69 | case ..<1_000: return "\(Int(meters.rounded())) m" | ||
| 70 | case ..<1e10: return "\(Int((meters / 1000).rounded()).formatted()) km" | ||
| 71 | case ..<(0.1 * lightYear): return "\(n(meters / au)) AU" | ||
| 72 | case ..<(1e6 * lightYear): return "\(n(meters / lightYear)) light-years" | ||
| 73 | case ..<(1e9 * lightYear): return "\(n(meters / lightYear / 1e6)) million light-years" | ||
| 74 | default: return "\(n(meters / lightYear / 1e9)) billion light-years" | ||
| 75 | } | ||
| 76 | } | ||
| 77 | } | ||
Packages/MoonriseKit/Tests/LibraryTests/LibraryTests.swift +19
| @@ -47,3 +47,22 @@ func linksPointAtThingsTheAppKnows(_ p: LibraryPage) { | |||
| 47 | #expect(Library.search(first.title).first?.id == first.id) | 47 | #expect(Library.search(first.title).first?.id == first.id) |
| 48 | #expect(Library.search(" ").isEmpty) | 48 | #expect(Library.search(" ").isEmpty) |
| 49 | } | 49 | } |
| 50 | |||
| 51 | @Test func scaleObjectsComeFromPages() { | ||
| 52 | let objects = Library.scaleObjects | ||
| 53 | #expect(objects.first?.name == "ISS") | ||
| 54 | #expect(objects.last?.name == "Observable universe") | ||
| 55 | #expect(objects.contains { $0.name == "Earth" && abs($0.meters - 12_742_000) < 1 }) | ||
| 56 | #expect(objects.contains { $0.page == "arcturus" }) | ||
| 57 | #expect(objects.contains { $0.page == "m31" }) | ||
| 58 | #expect(Set(objects.map(\.name)).count == objects.count) | ||
| 59 | for o in objects where o.page != nil { #expect(Library.page(id: o.page!) != nil, "\(o.name)") } | ||
| 60 | } | ||
| 61 | |||
| 62 | @Test func scaleSizeFormats() { | ||
| 63 | #expect(Library.formatSize(109) == "109 m") | ||
| 64 | #expect(Library.formatSize(12_742_000) == "12,742 km") | ||
| 65 | #expect(Library.formatSize(2 * 30.07 * 1.495978707e11) == "60.1 AU") | ||
| 66 | #expect(Library.formatSize(100_000 * 9.4607e15) == "100,000 light-years") | ||
| 67 | #expect(Library.formatSize(93e9 * 9.4607e15) == "93 billion light-years") | ||
| 68 | } | ||