From 8324ce81999c5f0c931a322b185b158dc4ed7a4e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 23:31:42 +0000 Subject: [PATCH 1/7] Fix solar chart to use variable amplitude instead of moving horizon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chart previously used a fixed-amplitude sine curve (always -1 to +1) and positioned the "horizon" line wherever the curve happened to be at sunrise time, causing the horizon to drift up in winter and down in summer. Now yValue(for:) subtracts the sine value at sunrise so that y=0 is always the horizon. The arc's amplitude above y=0 at solar noon scales naturally with daylight duration: larger on long summer days, smaller on short winter days — matching how the sun actually arcs higher in the sky in summer. The horizonY calculation in chartOverlayContent already derives from yValue(for: safeSunrise), so it automatically locks to y=0 with no additional changes needed. https://claude.ai/code/session_01UmjLxdTg5ZePkPaQDBe4rf --- Solstice/Charts/DaylightChart.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Solstice/Charts/DaylightChart.swift b/Solstice/Charts/DaylightChart.swift index 6cf4e8ab..910e9efb 100644 --- a/Solstice/Charts/DaylightChart.swift +++ b/Solstice/Charts/DaylightChart.swift @@ -324,7 +324,12 @@ extension DaylightChart { } func yValue(for date: Date) -> Double { - return sin(progressValue(for: date) * .pi * 2 - .pi / 2) + let raw = sin(progressValue(for: date) * .pi * 2 - .pi / 2) + // Shift the curve so sunrise/sunset land exactly on y=0 (the horizon). + // This makes the arc's amplitude above the horizon proportional to daylight + // duration rather than moving the horizon line up and down. + let sunriseOffset = sin(progressValue(for: solar.safeSunrise) * .pi * 2 - .pi / 2) + return raw - sunriseOffset } func scrub(to point: CGPoint, in geo: GeometryProxy, proxy: ChartProxy) { From 5421d99b2dfc5e1bde836ab3429f91d6321adc45 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 23:50:59 +0000 Subject: [PATCH 2/7] Add solstice comparison paths to solar chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draws two faint dashed reference arcs alongside the current day's path: one for the summer solstice (longest day) and one for the winter solstice (shortest day). This lets users see at a glance where today falls between the two extremes throughout the year. Implementation: - solsticeNTSolars computes NTSolar for Jun 21 and Dec 21, then labels them longer/shorter by comparing daylightDuration — automatically correct for both hemispheres. - todayEquivalentSunrise maps a solstice's sunrise time-of-day onto today's x-axis as a simple time-offset from midnight, so the path is drawn against the same 24-hour range. Polar day (safeSunrise = endOfDay) and polar night (safeSunrise = noon) both map through cleanly. - yValue(for:withSunriseAt:) applies the same amplitude formula as the main path but with a supplied sunrise reference, keeping the horizon consistent at y=0 across all three curves. - If either solstice NTSolar can't be computed the comparison paths are simply omitted with no visual or runtime impact. https://claude.ai/code/session_01UmjLxdTg5ZePkPaQDBe4rf --- Solstice/Charts/DaylightChart.swift | 64 ++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/Solstice/Charts/DaylightChart.swift b/Solstice/Charts/DaylightChart.swift index 910e9efb..c7cdcccc 100644 --- a/Solstice/Charts/DaylightChart.swift +++ b/Solstice/Charts/DaylightChart.swift @@ -104,7 +104,13 @@ struct DaylightChart: View { } } .chartBackground { proxy in - solarPathView(proxy: proxy) + ZStack { + if let solstices = solsticeNTSolars { + solsticeComparisonPath(proxy: proxy, solsticeSolar: solstices.shorter) + solsticeComparisonPath(proxy: proxy, solsticeSolar: solstices.longer) + } + solarPathView(proxy: proxy) + } } } @@ -272,6 +278,24 @@ struct DaylightChart: View { .fill(solarPathGradient) } + private func solsticeComparisonPath(proxy: ChartProxy, solsticeSolar: NTSolar) -> some View { + let sunriseRef = todayEquivalentSunrise(for: solsticeSolar) + return Path { path in + if let firstPoint = hours.first, + let x = proxy.position(forX: firstPoint), + let y = proxy.position(forY: yValue(for: firstPoint, withSunriseAt: sunriseRef)) { + 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, withSunriseAt: sunriseRef)) ?? 0 + path.addLine(to: CGPoint(x: x, y: y)) + } + } + .strokedPath(StrokeStyle(lineWidth: max(1, markSize * 0.5), lineCap: .round, lineJoin: .round, dash: [4, 4])) + .fill(.secondary.opacity(0.25)) + } + private var solarPathGradient: LinearGradient { LinearGradient(stops: [ Gradient.Stop(color: .secondary.opacity(0), location: 0), @@ -323,6 +347,44 @@ extension DaylightChart { return (date.distance(to: startOfDay) - culminationDelta) / dayLength } + /// NTSolar instances for the summer (longer) and winter (shorter) solstices of + /// the current year at this location. Correctly identifies which is which for + /// both hemispheres, and returns nil if either solstice can't be computed + /// (e.g. extreme polar coordinates). + var solsticeNTSolars: (longer: NTSolar, shorter: NTSolar)? { + var calendar = Calendar.current + calendar.timeZone = timeZone + let year = calendar.component(.year, from: solar.date) + guard + let june21 = calendar.date(from: DateComponents(year: year, month: 6, day: 21)), + let dec21 = calendar.date(from: DateComponents(year: year, month: 12, day: 21)), + let juneSolar = NTSolar(for: june21, coordinate: solar.coordinate, timeZone: timeZone), + let decSolar = NTSolar(for: dec21, coordinate: solar.coordinate, timeZone: timeZone) + else { return nil } + return juneSolar.daylightDuration >= decSolar.daylightDuration + ? (longer: juneSolar, shorter: decSolar) + : (longer: decSolar, shorter: juneSolar) + } + + /// Maps a solstice's sunrise time-of-day onto today's x-axis so the path can + /// be drawn against the same time range. Works correctly for polar day/night: + /// safeSunrise returns noon (polar night) or end-of-day (polar day), both of + /// which map through cleanly as a fraction of the 24-hour period. + func todayEquivalentSunrise(for solsticeSolar: NTSolar) -> Date { + var cal = Calendar.current + cal.timeZone = timeZone + let offset = cal.startOfDay(for: solsticeSolar.date).distance(to: solsticeSolar.safeSunrise) + return startOfDay.addingTimeInterval(offset) + } + + /// y-value variant that uses an explicit sunrise reference date for amplitude, + /// used to draw solstice comparison paths with a different horizon offset. + func yValue(for date: Date, withSunriseAt sunrise: Date) -> Double { + let raw = sin(progressValue(for: date) * .pi * 2 - .pi / 2) + let sunriseOffset = sin(progressValue(for: sunrise) * .pi * 2 - .pi / 2) + return raw - sunriseOffset + } + func yValue(for date: Date) -> Double { let raw = sin(progressValue(for: date) * .pi * 2 - .pi / 2) // Shift the curve so sunrise/sunset land exactly on y=0 (the horizon). From 1d67ee184c0fef0d7377c3d8772ccffc9069c6da Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Mon, 23 Feb 2026 08:26:04 +0000 Subject: [PATCH 3/7] Adjust design of solstice lines --- .../xcdebugger/Breakpoints_v2.xcbkptlist | 48 ------------------- Solstice/Charts/DaylightChart.swift | 2 +- 2 files changed, 1 insertion(+), 49 deletions(-) diff --git a/Solstice.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Solstice.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index bc810b64..b6512d82 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 c7cdcccc..a2e8a3e6 100644 --- a/Solstice/Charts/DaylightChart.swift +++ b/Solstice/Charts/DaylightChart.swift @@ -292,7 +292,7 @@ struct DaylightChart: View { path.addLine(to: CGPoint(x: x, y: y)) } } - .strokedPath(StrokeStyle(lineWidth: max(1, markSize * 0.5), lineCap: .round, lineJoin: .round, dash: [4, 4])) + .strokedPath(StrokeStyle(lineWidth: max(1, markSize * 0.25), lineCap: .round, lineJoin: .round, dash: [8, 6])) .fill(.secondary.opacity(0.25)) } From d50a3f51ec8a7b3fe207f91d2ca304d8b52380f6 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Mon, 23 Feb 2026 08:39:05 +0000 Subject: [PATCH 4/7] Graph real solar altitude instead of a sine approximation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the chart used a manually-crafted sine wave whose only relationship to astronomy was the sunrise/sunset times (used to position the horizon and scale the amplitude). The curve was always perfectly sinusoidal regardless of latitude or time of year. This replaces that with actual altitude in degrees above/below the horizon, computed from first principles using the same Schlyter algorithms already present in NTSolar. NTSolar gains altitude(at:) using the standard spherical-astronomy formula: sin(alt) = sin(lat)sin(dec) + cos(lat)cos(dec)cos(HA), where RA/dec come from sun_RA_dec() and the local hour angle is derived from GMST0() + UT + longitude — all existing private helpers, so no new dependencies are introduced. DaylightChart changes: - yValue(for:) now returns solar.altitude(at:) in degrees - yValue(for:solsticeSolar:) maps today's time-of-day to the solstice date and calls solsticeSolar.altitude(at:), replacing the old sine-offset approach for the comparison paths - effectiveYScale auto-sizes from summer solstice noon altitude so the chart scale is stable year-round and location-appropriate - horizonY uses proxy.position(forY: 0.0) directly since 0° altitude is exactly the geometric horizon - yScale is now optional (nil = auto); widget override removed - Dead code removed: progressValue, culminationDelta, noonish, dayLength, endOfDay, daylightProportion, todayEquivalentSunrise, yValue(for:withSunriseAt:) Co-Authored-By: Claude Sonnet 4.6 --- Solstice/Charts/DaylightChart.swift | 81 ++++++++----------- Solstice/Helpers/NTSolar.swift | 37 +++++++++ .../SolarChartWidgetView.swift | 3 +- 3 files changed, 72 insertions(+), 49 deletions(-) diff --git a/Solstice/Charts/DaylightChart.swift b/Solstice/Charts/DaylightChart.swift index a2e8a3e6..9c0903b3 100644 --- a/Solstice/Charts/DaylightChart.swift +++ b/Solstice/Charts/DaylightChart.swift @@ -25,7 +25,7 @@ struct DaylightChart: View { 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 @@ -86,7 +86,7 @@ struct DaylightChart: View { } } .chartYAxis(.hidden) - .chartYScale(domain: yScale) + .chartYScale(domain: effectiveYScale) .chartXAxis(hideXAxis ? .hidden : .automatic) .chartXAxis { if !hideXAxis { @@ -136,7 +136,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) @@ -279,16 +279,15 @@ struct DaylightChart: View { } private func solsticeComparisonPath(proxy: ChartProxy, solsticeSolar: NTSolar) -> some View { - let sunriseRef = todayEquivalentSunrise(for: solsticeSolar) return Path { path in if let firstPoint = hours.first, let x = proxy.position(forX: firstPoint), - let y = proxy.position(forY: yValue(for: firstPoint, withSunriseAt: sunriseRef)) { + let y = proxy.position(forY: yValue(for: firstPoint, solsticeSolar: solsticeSolar)) { 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, withSunriseAt: sunriseRef)) ?? 0 + let y: CGFloat = proxy.position(forY: yValue(for: hour, solsticeSolar: solsticeSolar)) ?? 0 path.addLine(to: CGPoint(x: x, y: y)) } } @@ -309,19 +308,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: @@ -334,7 +323,7 @@ extension DaylightChart { return .primary } } - + func resetSelectedEvent() { selectedEvent = solar.events.filter { $0.phase == .sunset || $0.phase == .sunrise @@ -342,11 +331,7 @@ extension DaylightChart { a.date.compare(.now) == .orderedDescending }).first } - - func progressValue(for date: Date) -> Double { - return (date.distance(to: startOfDay) - culminationDelta) / dayLength - } - + /// NTSolar instances for the summer (longer) and winter (shorter) solstices of /// the current year at this location. Correctly identifies which is which for /// both hemispheres, and returns nil if either solstice can't be computed @@ -366,34 +351,36 @@ extension DaylightChart { : (longer: decSolar, shorter: juneSolar) } - /// Maps a solstice's sunrise time-of-day onto today's x-axis so the path can - /// be drawn against the same time range. Works correctly for polar day/night: - /// safeSunrise returns noon (polar night) or end-of-day (polar day), both of - /// which map through cleanly as a fraction of the 24-hour period. - func todayEquivalentSunrise(for solsticeSolar: NTSolar) -> Date { - var cal = Calendar.current - cal.timeZone = timeZone - let offset = cal.startOfDay(for: solsticeSolar.date).distance(to: solsticeSolar.safeSunrise) - return startOfDay.addingTimeInterval(offset) + /// The y-scale to use for the chart, sized to the summer solstice noon altitude + /// at this location so it stays stable across the year. Callers may override + /// via the `yScale` property. + private var effectiveYScale: ClosedRange { + if let yScale { return yScale } + guard let solstices = solsticeNTSolars, + let summerNoon = solstices.longer.solarNoon else { + return -90.0...90.0 + } + let maxAlt = min(90.0, max(15.0, solstices.longer.altitude(at: summerNoon))) + let padding = maxAlt * 0.3 + return (-maxAlt - padding)...(maxAlt + padding) } - /// y-value variant that uses an explicit sunrise reference date for amplitude, - /// used to draw solstice comparison paths with a different horizon offset. - func yValue(for date: Date, withSunriseAt sunrise: Date) -> Double { - let raw = sin(progressValue(for: date) * .pi * 2 - .pi / 2) - let sunriseOffset = sin(progressValue(for: sunrise) * .pi * 2 - .pi / 2) - return raw - sunriseOffset + /// 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 { + solar.altitude(at: date) } - func yValue(for date: Date) -> Double { - let raw = sin(progressValue(for: date) * .pi * 2 - .pi / 2) - // Shift the curve so sunrise/sunset land exactly on y=0 (the horizon). - // This makes the arc's amplitude above the horizon proportional to daylight - // duration rather than moving the horizon line up and down. - let sunriseOffset = sin(progressValue(for: solar.safeSunrise) * .pi * 2 - .pi / 2) - return raw - sunriseOffset + /// The sun's altitude in degrees at `date`'s time-of-day, evaluated as if + /// that moment were on the solstice day. Used for comparison path rendering. + func yValue(for date: Date, solsticeSolar: NTSolar) -> Double { + var cal = Calendar.current + cal.timeZone = timeZone + let offset = startOfDay.distance(to: date) + let solsticeDate = cal.startOfDay(for: solsticeSolar.date).addingTimeInterval(offset) + return solsticeSolar.altitude(at: solsticeDate) } - + func scrub(to point: CGPoint, in geo: GeometryProxy, proxy: ChartProxy) { var start: Double = 0 diff --git a/Solstice/Helpers/NTSolar.swift b/Solstice/Helpers/NTSolar.swift index f55f70b6..6c91fff2 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 c50050d8..414076b2 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 { From 5fb16054425ca5cf96d4ab953d6e38dd0d2359a8 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Mon, 23 Feb 2026 09:00:14 +0000 Subject: [PATCH 5/7] Use LineMark with catmullRom interpolation for solar path Replaces the manual Path-based drawing in chartBackground with native LineMark series inside Chart{}, applying .interpolationMethod(.catmullRom) for smooth curves. This eliminates the jagged appearance at equatorial locations caused by the low 30-minute sample rate with straight-line segments. The solstice comparison paths and today's solar path are all now LineMark series, with .chartLegend(.hidden) suppressing the auto-generated legend. Co-Authored-By: Claude Sonnet 4.6 --- Localizable.xcstrings | 6 ++ Solstice/Charts/DaylightChart.swift | 88 +++++++++++++---------------- 2 files changed, 44 insertions(+), 50 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index fd900f83..1fdc43e4 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -311,6 +311,9 @@ } } } + }, + "Altitude" : { + }, "Appearance" : { "localizations" : { @@ -1732,6 +1735,9 @@ } } } + }, + "Series" : { + }, "Settings" : { "localizations" : { diff --git a/Solstice/Charts/DaylightChart.swift b/Solstice/Charts/DaylightChart.swift index 9c0903b3..5e816252 100644 --- a/Solstice/Charts/DaylightChart.swift +++ b/Solstice/Charts/DaylightChart.swift @@ -81,10 +81,46 @@ struct DaylightChart: View { private var chartContent: some View { Chart { + if let solstices = solsticeNTSolars { + ForEach(hours, id: \.self) { hour in + LineMark( + x: .value("Time", hour), + y: .value("Altitude", yValue(for: hour, solsticeSolar: solstices.shorter)), + series: .value("Series", "shorter") + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(.secondary.opacity(0.25)) + .lineStyle(StrokeStyle(lineWidth: max(1, markSize * 0.25), lineCap: .round, lineJoin: .round, dash: [8, 6])) + } + + ForEach(hours, id: \.self) { hour in + LineMark( + x: .value("Time", hour), + y: .value("Altitude", yValue(for: hour, solsticeSolar: solstices.longer)), + series: .value("Series", "longer") + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(.secondary.opacity(0.25)) + .lineStyle(StrokeStyle(lineWidth: max(1, markSize * 0.25), lineCap: .round, lineJoin: .round, dash: [8, 6])) + } + } + + ForEach(hours, id: \.self) { hour in + LineMark( + x: .value("Time", hour), + y: .value("Altitude", yValue(for: hour)), + series: .value("Series", "today") + ) + .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: effectiveYScale) .chartXAxis(hideXAxis ? .hidden : .automatic) @@ -103,15 +139,7 @@ struct DaylightChart: View { chartOverlayContent(proxy: proxy, geo: geo) } } - .chartBackground { proxy in - ZStack { - if let solstices = solsticeNTSolars { - solsticeComparisonPath(proxy: proxy, solsticeSolar: solstices.shorter) - solsticeComparisonPath(proxy: proxy, solsticeSolar: solstices.longer) - } - solarPathView(proxy: proxy) - } - } + .animation(nil, value: solar.date) } private var filteredEvents: [NTSolar.Event] { @@ -147,6 +175,7 @@ struct DaylightChart: View { sunAboveHorizon(proxy: proxy, horizonY: horizonY) } } + .animation(nil, value: solar.date) scrubHitArea(geo: geo, proxy: proxy) } @@ -254,47 +283,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 func solsticeComparisonPath(proxy: ChartProxy, solsticeSolar: NTSolar) -> some View { - return Path { path in - if let firstPoint = hours.first, - let x = proxy.position(forX: firstPoint), - let y = proxy.position(forY: yValue(for: firstPoint, solsticeSolar: solsticeSolar)) { - 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, solsticeSolar: solsticeSolar)) ?? 0 - path.addLine(to: CGPoint(x: x, y: y)) - } - } - .strokedPath(StrokeStyle(lineWidth: max(1, markSize * 0.25), lineCap: .round, lineJoin: .round, dash: [8, 6])) - .fill(.secondary.opacity(0.25)) - } - private var solarPathGradient: LinearGradient { LinearGradient(stops: [ Gradient.Stop(color: .secondary.opacity(0), location: 0), From 6ef4a8ef07a836017275e2a16a09f92c97d61b15 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Tue, 3 Mar 2026 11:00:50 +0000 Subject: [PATCH 6/7] Remove solstice comparison paths; fit y-scale to current day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove summer/winter solstice overlay paths and all related code (solsticeNTSolars, yValue(for:solsticeSolar:), SolarPathPoint, etc.) - Rewrite effectiveYScale to fit the actual min/max altitudes of today's path (with 10% padding), fixing path clipping at the bottom of the chart - Increase chart height (300→360) and reduce default mark size (8→6) Co-Authored-By: Claude Sonnet 4.6 --- Localizable.xcstrings | 3 - Solstice/Charts/DaylightChart.swift | 80 ++++-------------------- Solstice/Detail View/DailyOverview.swift | 3 - Solstice/Extensions/AppStorage++.swift | 1 + Solstice/Helpers/Globals.swift | 4 +- 5 files changed, 16 insertions(+), 75 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 1fdc43e4..3081ac4e 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -1735,9 +1735,6 @@ } } } - }, - "Series" : { - }, "Settings" : { "localizations" : { diff --git a/Solstice/Charts/DaylightChart.swift b/Solstice/Charts/DaylightChart.swift index 5e816252..329bbad1 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: ClosedRange? = nil - + var plotDate: Date { currentX ?? solar.date } @@ -81,35 +81,10 @@ struct DaylightChart: View { private var chartContent: some View { Chart { - if let solstices = solsticeNTSolars { - ForEach(hours, id: \.self) { hour in - LineMark( - x: .value("Time", hour), - y: .value("Altitude", yValue(for: hour, solsticeSolar: solstices.shorter)), - series: .value("Series", "shorter") - ) - .interpolationMethod(.catmullRom) - .foregroundStyle(.secondary.opacity(0.25)) - .lineStyle(StrokeStyle(lineWidth: max(1, markSize * 0.25), lineCap: .round, lineJoin: .round, dash: [8, 6])) - } - - ForEach(hours, id: \.self) { hour in - LineMark( - x: .value("Time", hour), - y: .value("Altitude", yValue(for: hour, solsticeSolar: solstices.longer)), - series: .value("Series", "longer") - ) - .interpolationMethod(.catmullRom) - .foregroundStyle(.secondary.opacity(0.25)) - .lineStyle(StrokeStyle(lineWidth: max(1, markSize * 0.25), lineCap: .round, lineJoin: .round, dash: [8, 6])) - } - } - ForEach(hours, id: \.self) { hour in LineMark( x: .value("Time", hour), - y: .value("Altitude", yValue(for: hour)), - series: .value("Series", "today") + y: .value("Altitude", yValue(for: hour)) ) .interpolationMethod(.catmullRom) .foregroundStyle(solarPathGradient) @@ -320,37 +295,18 @@ extension DaylightChart { }).first } - /// NTSolar instances for the summer (longer) and winter (shorter) solstices of - /// the current year at this location. Correctly identifies which is which for - /// both hemispheres, and returns nil if either solstice can't be computed - /// (e.g. extreme polar coordinates). - var solsticeNTSolars: (longer: NTSolar, shorter: NTSolar)? { - var calendar = Calendar.current - calendar.timeZone = timeZone - let year = calendar.component(.year, from: solar.date) - guard - let june21 = calendar.date(from: DateComponents(year: year, month: 6, day: 21)), - let dec21 = calendar.date(from: DateComponents(year: year, month: 12, day: 21)), - let juneSolar = NTSolar(for: june21, coordinate: solar.coordinate, timeZone: timeZone), - let decSolar = NTSolar(for: dec21, coordinate: solar.coordinate, timeZone: timeZone) - else { return nil } - return juneSolar.daylightDuration >= decSolar.daylightDuration - ? (longer: juneSolar, shorter: decSolar) - : (longer: decSolar, shorter: juneSolar) - } - - /// The y-scale to use for the chart, sized to the summer solstice noon altitude - /// at this location so it stays stable across the year. Callers may override - /// via the `yScale` property. + /// 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 } - guard let solstices = solsticeNTSolars, - let summerNoon = solstices.longer.solarNoon else { + let altitudes = hours.map { yValue(for: $0) } + guard let minAlt = altitudes.min(), let maxAlt = altitudes.max() else { return -90.0...90.0 } - let maxAlt = min(90.0, max(15.0, solstices.longer.altitude(at: summerNoon))) - let padding = maxAlt * 0.3 - return (-maxAlt - padding)...(maxAlt + padding) + 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. @@ -359,16 +315,6 @@ extension DaylightChart { solar.altitude(at: date) } - /// The sun's altitude in degrees at `date`'s time-of-day, evaluated as if - /// that moment were on the solstice day. Used for comparison path rendering. - func yValue(for date: Date, solsticeSolar: NTSolar) -> Double { - var cal = Calendar.current - cal.timeZone = timeZone - let offset = startOfDay.distance(to: date) - let solsticeDate = cal.startOfDay(for: solsticeSolar.date).addingTimeInterval(offset) - return solsticeSolar.altitude(at: solsticeDate) - } - 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 7d68a1f6..da7a9dbb 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 1eff036b..3b9a6a7a 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 13aa0bb8..e41fc457 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 }() From 7980c7f8222e0a3adddf7da9c21502cd67921f15 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Tue, 3 Mar 2026 11:20:14 +0000 Subject: [PATCH 7/7] Bump version number --- Solstice.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Solstice.xcodeproj/project.pbxproj b/Solstice.xcodeproj/project.pbxproj index a86a3288..15a4ad5e 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;