Created
December 5, 2025 12:23
-
-
Save lanserxt/1697dcb243bbc436333f6550f8eaa101 to your computer and use it in GitHub Desktop.
SwiftUI: Charts Interactions - Part 2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import SwiftUI | |
| import Charts | |
| struct HumidityRate: Identifiable { | |
| let humidity: Int | |
| let date: Date | |
| var id: Date { date } | |
| init(minutesOffset: Double, humidity: Int) { | |
| self.date = Date().addingTimeInterval(minutesOffset * 60) | |
| self.humidity = humidity | |
| } | |
| } | |
| extension HumidityRate { | |
| static var samples: [HumidityRate] { | |
| [ | |
| .init(minutesOffset: -3, humidity: 10), | |
| .init(minutesOffset: -2, humidity: 10), | |
| .init(minutesOffset: -1, humidity: 10), | |
| .init(minutesOffset: 0, humidity: 20), | |
| .init(minutesOffset: 1, humidity: 30), | |
| .init(minutesOffset: 2, humidity: 40), | |
| .init(minutesOffset: 3, humidity: 50), | |
| .init(minutesOffset: 4, humidity: 40), | |
| .init(minutesOffset: 5, humidity: 30), | |
| .init(minutesOffset: 6, humidity: 20), | |
| .init(minutesOffset: 7, humidity: 10) | |
| ] | |
| } | |
| } | |
| struct HumidityChartView: View { | |
| @State private var selectedX: Date? = nil | |
| @State private var selectedY: Int? = nil | |
| let data: [HumidityRate] | |
| var body: some View { | |
| Chart(data, id: \.date) { rate in | |
| AreaMark(x: .value("", rate.date), | |
| y: .value("", rate.humidity)) | |
| .foregroundStyle(LinearGradient(colors: | |
| data.compactMap({ | |
| Color.colorForIndex($0.humidity) | |
| }), | |
| startPoint: .leading, | |
| endPoint: .trailing)) | |
| .interpolationMethod(.catmullRom) | |
| LineMark( | |
| x: .value("", rate.date), | |
| y: .value("", rate.humidity) | |
| ) | |
| .interpolationMethod(.catmullRom) | |
| .foregroundStyle(LinearGradient(colors: data.compactMap({Color.colorForIndex($0.humidity)}), startPoint: .leading, endPoint: .trailing)) | |
| PointMark( | |
| x: .value("", rate.date), | |
| y: .value("", rate.humidity) | |
| ) | |
| .foregroundStyle(Color.colorForIndex(rate.humidity)) | |
| .annotation(position: .top) { | |
| Text("\(rate.humidity)") | |
| .font(.headline) | |
| .fontWeight( rate.date < Date() ? .regular: .bold) | |
| .opacity( rate.date < Date() ? 0.5 : 1.0) | |
| }.annotation(position: .automatic, alignment: .center, spacing: -9.0) { | |
| Circle() | |
| .stroke(Color.black.opacity(0.5), lineWidth: 1) | |
| } | |
| if let selectedX { | |
| RuleMark(x: .value("Selected", selectedX)) | |
| .annotation(position: .top) { | |
| VStack(spacing: 2) { | |
| Text(selectedX, style: .time) | |
| } | |
| } | |
| if let selectedY { | |
| PointMark( | |
| x: .value("X", selectedX), | |
| y: .value("Y", selectedY) | |
| ) | |
| } | |
| } | |
| } | |
| .chartXAxis(content: { | |
| AxisMarks( | |
| position: .bottom, values: data.compactMap(\.date) | |
| ){ value in | |
| if let date = value.as(Date.self) { | |
| AxisGridLine(stroke: .init(lineWidth: 1)) | |
| if data.compactMap(\.date).evenIndexed().contains(date) && data.compactMap(\.date).last != date { | |
| AxisValueLabel { | |
| VStack(alignment: .center) { | |
| Text(date, format: .dateTime.hour().minute()) | |
| .font(.footnote) | |
| .opacity( date < Date() ? 0.5 : 1.0) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }) | |
| .chartYAxis(content: { | |
| AxisMarks( | |
| position: .trailing, values: Array(stride(from: 0, | |
| to: min(100, (data.map(\.humidity).max() ?? 0) + 10), | |
| by: 10) | |
| ) ){ value in | |
| if let number = value.as(Int.self) { | |
| if [20, 50].contains(number) { | |
| AxisGridLine(stroke: .init(lineWidth: 2)) | |
| .foregroundStyle(Color.colorForIndex(number)) | |
| AxisValueLabel { | |
| VStack(alignment: .leading) { | |
| Text("\(number)") | |
| .fontWeight(.bold) | |
| } | |
| .foregroundStyle(Color.colorForIndex(number)) | |
| } | |
| } else { | |
| AxisGridLine(stroke: .init(lineWidth: 1)) | |
| AxisValueLabel { | |
| VStack(alignment: .leading) { | |
| Text("\(number)") | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }) | |
| .chartOverlay { proxy in | |
| GeometryReader { geometry in | |
| Rectangle().fill(.clear).contentShape(Rectangle()) | |
| .gesture( | |
| DragGesture() | |
| .onChanged { value in | |
| // Convert the gesture location to the coordinate space of the plot area. | |
| guard let plotFrame = proxy.plotFrame else {return} | |
| let origin = geometry[plotFrame].origin | |
| let location = CGPoint( | |
| x: value.location.x - origin.x, | |
| y: value.location.y - origin.y | |
| ) | |
| // Get the x (date) and y (humidity) value from the location. | |
| let (date, humidity) = proxy.value(at: location, as: (Date, Int).self) ?? (Date(), 0) | |
| if let firstDate = data.first?.date, let lastDate = data.last?.date { | |
| if date >= firstDate && date <= lastDate { | |
| //Setting selected date | |
| selectedX = date | |
| //Calculating Y-value | |
| if let humidity = interpolatedHumidity(at: date, data: data) { | |
| selectedY = Int(humidity) | |
| } | |
| } | |
| } | |
| } | |
| .onEnded({ _ in | |
| selectedX = nil | |
| }) | |
| ) | |
| } | |
| } | |
| .frame(height: 400) | |
| .padding() | |
| } | |
| // Returns an interpolated humidity value at a specific date using Catmull–Rom spline interpolation. | |
| /// - Parameters: | |
| /// - date: The target date for interpolation. | |
| /// - data: Array of humidity points sorted by date. | |
| /// - Returns: Interpolated humidity as `Double`, or `nil` if interpolation cannot be performed. | |
| func interpolatedHumidity(at date: Date, data: [HumidityRate]) -> Double? { | |
| // Need at least 4 control points for Catmull–Rom interpolation. | |
| guard data.count >= 4 else { return nil } | |
| // Find the first index where the data point's date is >= target date. | |
| // This identifies the segment the target date falls into. | |
| guard let idx = data.firstIndex(where: { $0.date >= date }), idx > 0 else { | |
| return nil | |
| } | |
| // Select four surrounding control points (with safe bounds) | |
| let i0 = max(0, idx - 2) | |
| let i1 = max(0, idx - 1) | |
| let i2 = idx | |
| let i3 = min(data.count - 1, idx + 1) | |
| // Extract humidity values (converted to Double for interpolation) | |
| let p0 = Double(data[i0].humidity) | |
| let p1 = Double(data[i1].humidity) | |
| let p2 = Double(data[i2].humidity) | |
| let p3 = Double(data[i3].humidity) | |
| // Compute normalized parameter t in [0, 1] along the i1–i2 segment | |
| let x1 = data[i1].date.timeIntervalSince1970 | |
| let x2 = data[i2].date.timeIntervalSince1970 | |
| let t = (date.timeIntervalSince1970 - x1) / (x2 - x1) | |
| // Final Catmull–Rom interpolation | |
| return catmullRom(p0, p1, p2, p3, t: t) | |
| } | |
| func catmullRom(_ p0: Double, _ p1: Double, _ p2: Double, _ p3: Double, t: Double) -> Double { | |
| let t2 = t * t | |
| let t3 = t2 * t | |
| return 0.5 * ( | |
| (2 * p1) + | |
| (-p0 + p2) * t + | |
| (2*p0 - 5*p1 + 4*p2 - p3) * t2 + | |
| (-p0 + 3*p1 - 3*p2 + p3) * t3 | |
| ) | |
| } | |
| } | |
| extension Color { | |
| static func colorForIndex(_ humidity: Int) -> Color { | |
| switch humidity { | |
| case 0..<20: return .green | |
| case 20..<50: return .yellow | |
| case 50..<70: return .orange | |
| case 70...80: return .red | |
| default: return .purple | |
| } | |
| } | |
| } | |
| extension Date { | |
| func closestDate(in dates: [Date]) -> Self { | |
| guard !dates.isEmpty else { return self } | |
| return dates.min(by: { abs($0.timeIntervalSince(self)) < abs($1.timeIntervalSince(self)) }) ?? self | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment