diff --git a/Localizable.xcstrings b/Localizable.xcstrings index fd900f83a..3081ac4e3 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -311,6 +311,9 @@ } } } + }, + "Altitude" : { + }, "Appearance" : { "localizations" : { diff --git a/Solstice.xcodeproj/project.pbxproj b/Solstice.xcodeproj/project.pbxproj index a86a3288b..15a4ad5e6 100644 --- a/Solstice.xcodeproj/project.pbxproj +++ b/Solstice.xcodeproj/project.pbxproj @@ -1809,7 +1809,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Solstice uses your location to calculate local sunrise and sunset times"; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 3.1; + MARKETING_VERSION = 3.1.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -1871,7 +1871,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Solstice uses your location to calculate local sunrise and sunset times"; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 3.1; + MARKETING_VERSION = 3.1.1; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; diff --git a/Solstice.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Solstice.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index bc810b64f..b6512d828 100644 --- a/Solstice.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Solstice.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -3,52 +3,4 @@ uuid = "694A27F2-72FC-45F0-A4E5-28318A89FE23" type = "1" version = "2.0"> - - - - - - - - - - - - diff --git a/Solstice/Charts/DaylightChart.swift b/Solstice/Charts/DaylightChart.swift index 6cf4e8ab4..329bbad15 100644 --- a/Solstice/Charts/DaylightChart.swift +++ b/Solstice/Charts/DaylightChart.swift @@ -12,21 +12,21 @@ import Suite struct DaylightChart: View { @Environment(\.isLuminanceReduced) var isLuminanceReduced @Environment(\.colorScheme) var colorScheme - + @State private var selectedEvent: NTSolar.Event? @State private var currentX: Date? - + var solar: NTSolar var timeZone: TimeZone var showEventTypes = true - + var appearance = Appearance.simple var includesSummaryTitle = true var hideXAxis = false var scrubbable = false var markSize: CGFloat = 6 - var yScale = -1.5...1.5 - + var yScale: ClosedRange? = nil + var plotDate: Date { currentX ?? solar.date } @@ -81,12 +81,23 @@ struct DaylightChart: View { private var chartContent: some View { Chart { + ForEach(hours, id: \.self) { hour in + LineMark( + x: .value("Time", hour), + y: .value("Altitude", yValue(for: hour)) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(solarPathGradient) + .lineStyle(StrokeStyle(lineWidth: markSize, lineCap: .round, lineJoin: .round)) + } + ForEach(filteredEvents, id: \.id) { solarEvent in eventPointMark(for: solarEvent) } } + .chartLegend(.hidden) .chartYAxis(.hidden) - .chartYScale(domain: yScale) + .chartYScale(domain: effectiveYScale) .chartXAxis(hideXAxis ? .hidden : .automatic) .chartXAxis { if !hideXAxis { @@ -103,9 +114,7 @@ struct DaylightChart: View { chartOverlayContent(proxy: proxy, geo: geo) } } - .chartBackground { proxy in - solarPathView(proxy: proxy) - } + .animation(nil, value: solar.date) } private var filteredEvents: [NTSolar.Event] { @@ -130,7 +139,7 @@ struct DaylightChart: View { @ViewBuilder private func chartOverlayContent(proxy: ChartProxy, geo: GeometryProxy) -> some View { - let horizonY: CGFloat = proxy.position(forY: yValue(for: solar.safeSunrise)) ?? 0 + let horizonY: CGFloat = proxy.position(forY: 0.0) ?? 0 Group { horizonLine(width: geo.size.width, yOffset: horizonY) @@ -141,6 +150,7 @@ struct DaylightChart: View { sunAboveHorizon(proxy: proxy, horizonY: horizonY) } } + .animation(nil, value: solar.date) scrubHitArea(geo: geo, proxy: proxy) } @@ -248,30 +258,6 @@ struct DaylightChart: View { } } - private func solarPathView(proxy: ChartProxy) -> some View { - Path { path in - if let firstPoint = hours.first, - let x = proxy.position(forX: firstPoint), - let y = proxy.position(forY: yValue(for: firstPoint)) { - path.move(to: CGPoint(x: x, y: y)) - } - - hours.forEach { hour in - let x: CGFloat = proxy.position(forX: hour) ?? 0 - let y: CGFloat = proxy.position(forY: yValue(for: hour)) ?? 0 - path.addLine(to: CGPoint(x: x, y: y)) - } - - if let lastPoint = hours.last, - let x = proxy.position(forX: lastPoint), - let y = proxy.position(forY: yValue(for: lastPoint)) { - path.move(to: CGPoint(x: x, y: y)) - } - } - .strokedPath(StrokeStyle(lineWidth: markSize, lineCap: .round, lineJoin: .round)) - .fill(solarPathGradient) - } - private var solarPathGradient: LinearGradient { LinearGradient(stops: [ Gradient.Stop(color: .secondary.opacity(0), location: 0), @@ -285,19 +271,9 @@ extension DaylightChart { var hours: Array { stride(from: range.lowerBound, through: range.upperBound, by: 60 * 30).compactMap { $0 } } - + var startOfDay: Date { range.lowerBound } - var endOfDay: Date { range.upperBound } - var dayLength: TimeInterval { .twentyFourHours } - - var noonish: Date { startOfDay.addingTimeInterval(TimeInterval.twentyFourHours / 2) } - - var culminationDelta: TimeInterval { solar.solarNoon?.distance(to: noonish) ?? 0 } - - var daylightProportion: Double { - solar.daylightDuration / dayLength - } - + func pointMarkColor(for eventPhase: NTSolar.Phase) -> HierarchicalShapeStyle { switch eventPhase { case .astronomical: @@ -310,7 +286,7 @@ extension DaylightChart { return .primary } } - + func resetSelectedEvent() { selectedEvent = solar.events.filter { $0.phase == .sunset || $0.phase == .sunrise @@ -318,15 +294,27 @@ extension DaylightChart { a.date.compare(.now) == .orderedDescending }).first } - - func progressValue(for date: Date) -> Double { - return (date.distance(to: startOfDay) - culminationDelta) / dayLength + + /// The y-scale to use for the chart, fitted to the actual min/max altitudes + /// across all sampled hours with padding so the path never crowds the edges. + /// Callers may override via the `yScale` property. + private var effectiveYScale: ClosedRange { + if let yScale { return yScale } + let altitudes = hours.map { yValue(for: $0) } + guard let minAlt = altitudes.min(), let maxAlt = altitudes.max() else { + return -90.0...90.0 + } + let span = maxAlt - minAlt + let padding = span * 0.1 + return (minAlt - padding)...(maxAlt + padding) } - + + /// The sun's actual altitude in degrees at `date` for the current solar. + /// 0° is the geometric horizon; positive = above, negative = below. func yValue(for date: Date) -> Double { - return sin(progressValue(for: date) * .pi * 2 - .pi / 2) + solar.altitude(at: date) } - + func scrub(to point: CGPoint, in geo: GeometryProxy, proxy: ChartProxy) { var start: Double = 0 diff --git a/Solstice/Detail View/DailyOverview.swift b/Solstice/Detail View/DailyOverview.swift index 7d68a1f62..da7a9dbbd 100644 --- a/Solstice/Detail View/DailyOverview.swift +++ b/Solstice/Detail View/DailyOverview.swift @@ -95,9 +95,6 @@ struct DailyOverview: View { .background(Color("listRowBackgroundColor")) ) #endif - #if !os(macOS) - .menuActionDismissBehavior(.disabled) - #endif #endif Group { diff --git a/Solstice/Extensions/AppStorage++.swift b/Solstice/Extensions/AppStorage++.swift index 1eff036b6..3b9a6a7ae 100644 --- a/Solstice/Extensions/AppStorage++.swift +++ b/Solstice/Extensions/AppStorage++.swift @@ -137,6 +137,7 @@ struct Preferences { static let timeTravelAppearance: Value = ("timeTravelAppearance", .expanded) static let chartType: Value = ("chartType", .classic) + static let showSolsticesInChart: Value = ("showSolsticesInChart", false) } enum TimeTravelAppearance: String, CaseIterable, RawRepresentable, Identifiable { diff --git a/Solstice/Helpers/Globals.swift b/Solstice/Helpers/Globals.swift index 13aa0bb84..e41fc457e 100644 --- a/Solstice/Helpers/Globals.swift +++ b/Solstice/Helpers/Globals.swift @@ -9,7 +9,7 @@ import Foundation var chartHeight: CGFloat = { #if !os(watchOS) - 300 + 360 #else 200 #endif @@ -19,7 +19,7 @@ var chartMarkSize: Double = { #if os(watchOS) 4 #else - 8 + 6 #endif }() diff --git a/Solstice/Helpers/NTSolar.swift b/Solstice/Helpers/NTSolar.swift index f55f70b67..6c91fff2b 100644 --- a/Solstice/Helpers/NTSolar.swift +++ b/Solstice/Helpers/NTSolar.swift @@ -679,3 +679,40 @@ struct NTSolar { } /* GMST0 */ } + +extension NTSolar { + /// Returns the sun's altitude above the horizon in degrees at the given instant. + /// Positive values are above the horizon, negative values are below. + /// + /// Uses the same astronomical algorithms as the rest of NTSolar (Schlyter's + /// method), so results are consistent with the sunrise/sunset times already + /// computed by this struct. + func altitude(at date: Date) -> Double { + var utcCal = Calendar(identifier: .gregorian) + utcCal.timeZone = TimeZone(secondsFromGMT: 0)! + let comps = utcCal.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) + guard let year = comps.year, let month = comps.month, let day = comps.day, + let hour = comps.hour, let minute = comps.minute, let second = comps.second + else { return 0 } + + // UT as a decimal hour + let UT = Double(hour) + Double(minute) / 60.0 + Double(second) / 3600.0 + + // Days since J2000.0 at the exact instant (used for both sun position and sidereal time) + let d = Double(NTSolar.days_since_2000_Jan_0(y: year, m: month, d: day)) + UT / 24.0 + + // Sun's equatorial coordinates at this instant + let (sRA, sdec, _) = NTSolar.sun_RA_dec(d: d) + + // Local Mean Sidereal Time in degrees: GMST0(d) + UT_in_degrees + longitude + let LMST = NTSolar.revolution(x: NTSolar.GMST0(d: d) + UT * 15.0 + coordinate.longitude) + + // Local Hour Angle: how far the sun has moved past the meridian + let HA = LMST - sRA + + // Standard altitude formula: sin(alt) = sin(lat)sin(dec) + cos(lat)cos(dec)cos(HA) + let sin_alt = NTSolar.sind(x: coordinate.latitude) * NTSolar.sind(x: sdec) + + NTSolar.cosd(x: coordinate.latitude) * NTSolar.cosd(x: sdec) * NTSolar.cosd(x: HA) + return NTSolar.asind(x: sin_alt) + } +} diff --git a/Widget/Solar Chart Widget/SolarChartWidgetView.swift b/Widget/Solar Chart Widget/SolarChartWidgetView.swift index c50050d8e..414076b27 100644 --- a/Widget/Solar Chart Widget/SolarChartWidgetView.swift +++ b/Widget/Solar Chart Widget/SolarChartWidgetView.swift @@ -44,8 +44,7 @@ struct SolarChartWidgetView: SolsticeWidgetView { timeZone: location.timeZone, showEventTypes: false, includesSummaryTitle: false, - markSize: 3, - yScale: -1.0...1.5 + markSize: 3 ) } } else if needsReconfiguration {