App/Sources/YearSheet.swift
135 lines · 5873 bytes
1import SwiftUI
2
3/// Picks a year for the historical sky, from 3000 BCE to 3000 CE.
4struct YearSheet: View {
5 @Binding var year: Int?
6 var south = false
7 @State private var draft: Int
8 @Environment(\.dismiss) private var dismiss
9
10 static let range = -2999...3000
11
12 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
13 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
14
15 private let moments: [(String, Int)] = [
16 ("Great Pyramid built, Thuban at the pole", -2559),
17 ("Ptolemy's Almagest", 150),
18 ("Supernova that made the Crab Nebula", 1054),
19 ("Galileo's telescope", 1610),
20 ("Apollo 11", 1969),
21 ("Polaris closest to the pole", 2100),
22 ]
23
24 init(year: Binding<Int?>, south: Bool = false) {
25 _year = year
26 self.south = south
27 _draft = State(initialValue: year.wrappedValue ?? Calendar.current.component(.year, from: .now))
28 }
29
30 static func label(_ y: Int) -> String { y > 0 ? "\(y) CE" : "\(1 - y) BCE" }
31
32 private var poleStar: (name: String, degrees: Double)? {
33 SkyModel.poleStar(at: SkyModel.date(.now, inYear: draft), south: south)
34 }
35
36 var body: some View {
37 NavigationStack {
38 ScrollView {
39 VStack(alignment: .leading, spacing: 0) {
40 Text("Sky in another year").font(.title.weight(.medium))
41 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
42 Text("Tonight's date and time in any year from 3000 BCE to 3000 CE. The stars drift with precession and their own motion.")
43 .font(.subheadline).foregroundStyle(ink.opacity(0.75))
44
45 yearPicker.padding(.top, 28)
46
47 Text("Moments").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8)
48 .foregroundStyle(ink.opacity(0.6)).padding(.top, 32).padding(.bottom, 4)
49 ForEach(moments, id: \.1) { m in moment(m.0, m.1) }
50
51 Text("Dates before 1582 are in the Julian calendar. Planet positions far from today are approximate.")
52 .font(.caption2).foregroundStyle(ink.opacity(0.5)).padding(.top, 20)
53 }
54 .padding(20)
55 .readableWidth()
56 }
57 .background(Color.black.ignoresSafeArea())
58 .foregroundStyle(ink)
59 .tint(accent)
60 .toolbar {
61 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
62 ToolbarItem(placement: .confirmationAction) {
63 Button("Show") {
64 year = draft == Calendar.current.component(.year, from: .now) ? nil : draft
65 dismiss()
66 }
67 .fontWeight(.semibold)
68 }
69 }
70 }
71 }
72
73 private var yearPicker: some View {
74 VStack(spacing: 14) {
75 VStack(spacing: 2) {
76 Text(String(draft > 0 ? draft : 1 - draft))
77 .font(.system(size: 64, weight: .light).monospacedDigit())
78 .contentTransition(.numericText())
79 Text(draft > 0 ? "CE" : "BCE").font(.subheadline.weight(.medium)).tracking(2).foregroundStyle(ink.opacity(0.7))
80 }
81 .frame(maxWidth: .infinity)
82 .accessibilityElement(children: .combine)
83 .accessibilityLabel(Self.label(draft))
84
85 if let p = poleStar {
86 Text(String(format: "Pole star: %@, %.1f° from the pole", p.name, p.degrees))
87 .font(.footnote).foregroundStyle(ink.opacity(0.75))
88 }
89
90 Slider(value: Binding(get: { Double(draft) }, set: { draft = Int($0.rounded()) }), in: Double(Self.range.lowerBound)...Double(Self.range.upperBound))
91 .accessibilityLabel("Year")
92 .accessibilityValue(Self.label(draft))
93 HStack {
94 Text("3000 BCE"); Spacer(); Text("1 CE"); Spacer(); Text("3000 CE")
95 }
96 .font(.caption2).foregroundStyle(ink.opacity(0.5))
97 .accessibilityHidden(true)
98
99 HStack(spacing: 8) {
100 step("−100", -100); step("−1", -1); step("+1", 1); step("+100", 100)
101 }
102 .padding(.top, 4)
103 }
104 .padding(20)
105 .background(ink.opacity(0.06), in: RoundedRectangle(cornerRadius: 20))
106 .animation(.snappy, value: draft)
107 }
108
109 private func step(_ title: String, _ by: Int) -> some View {
110 Button { draft = min(max(draft + by, Self.range.lowerBound), Self.range.upperBound) } label: {
111 Text(title).font(.subheadline.weight(.medium).monospacedDigit())
112 .frame(maxWidth: .infinity, minHeight: 40)
113 .background(ink.opacity(0.08), in: Capsule())
114 }
115 .buttonStyle(.plain)
116 .accessibilityLabel(by > 0 ? "\(by) years later" : "\(-by) years earlier")
117 }
118
119 private func moment(_ title: String, _ y: Int) -> some View {
120 Button { draft = y } label: {
121 HStack(alignment: .firstTextBaseline, spacing: 14) {
122 Text(Self.label(y)).font(.subheadline.weight(.medium).monospacedDigit())
123 .foregroundStyle(draft == y ? accent : ink.opacity(0.7))
124 .frame(width: 84, alignment: .leading)
125 Text(title).font(.subheadline)
126 Spacer(minLength: 0)
127 if draft == y { Image(systemName: "checkmark").font(.footnote.weight(.semibold)).foregroundStyle(accent) }
128 }
129 .padding(.vertical, 12)
130 .overlay(alignment: .bottom) { Rectangle().fill(ink.opacity(0.12)).frame(height: 0.5) }
131 .contentShape(Rectangle())
132 }
133 .buttonStyle(.plain)
134 }
135}