Skip to content

Instantly share code, notes, and snippets.

@lanserxt
Created December 5, 2025 12:23
Show Gist options
  • Select an option

  • Save lanserxt/1697dcb243bbc436333f6550f8eaa101 to your computer and use it in GitHub Desktop.

Select an option

Save lanserxt/1697dcb243bbc436333f6550f8eaa101 to your computer and use it in GitHub Desktop.
SwiftUI: Charts Interactions - Part 2
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