Building a Heart Rate Heat-Map with SwiftUI + HealthKit

Building a Heart Rate Heat-Map with SwiftUI + HealthKit

author: Wesley Matlock read_time: 5 —

Most running apps give you an average heart rate. “Nice job, your heart rate was 148 BPM.” Cool. But where on the run did you start to suffer? Where did your body shift into overdrive?

That’s what this post is all about: turning flat stats into full-color storytelling. We’re building a MapKit-based heatmap using HealthKit heart rate samples, syncing them with your GPS route to show exactly where you were chillin’ (blue) and where you were flirting with the blackout gods (red).

This system is a core part of a new app I’m working on called WorkoutRoutes — more on that soon. For now, we’re focusing on the heart of it: precise HealthKit sync, clean SwiftUI, and fast rendering with ~50 smart segments.

🫶 Quick thing before we keep going: If this post helps you rethink how to visualize heart rate data, smashing that 👏 button (you can hit it up to 50 times!) gets it in front of more iOS devs. Much appreciated.

💡 The Problem with Averages

A single number can’t capture your effort over time. You could coast downhill at 130 BPM and then hit 190 BPM on the final hill, but all the app shows is “average: 150.”

We need context. And that means mapping your heart rate onto your GPS data with time correlation.

🫀 Heart Rate Zones 101

First up, define your heart rate zones. Here’s a simple system we’re using:

```swift enum WorkoutHeartRateZone: CaseIterable, Identifiable {
case resting // < 100 BPM
case moderate // 100-139 BPM
case vigorous // 140-179 BPM
case maximum // 180+ BPM

var color: Color {  
    switch self {  
    case .resting: return .blue  
    case .moderate: return .green  
    case .vigorous: return .orange  
    case .maximum: return .red  
    }  
}  
var range: ClosedRange<Double> {  
    switch self {  
    case .resting: return 0...99  
    case .moderate: return 100...139  
    case .vigorous: return 140...179  
    case .maximum: return 180...300  
    }  
}  
static func from(heartRate: Double) -> WorkoutHeartRateZone {  
    allCases.first(where: { $0.range.contains(heartRate) }) ?? .resting  
}   } ```swift

🔐 HealthKit Authorization and Data Fetching

HealthKit gives us heart rate samples and GPS routes, but they live in separate APIs. First, request access:

```swift func requestAuthorization() async throws {
var typesToRead: Set = [
HKObjectType.workoutType(),
HKSeriesType.workoutRoute()
]

if let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate) {  
    typesToRead.insert(heartRateType)  
}  
  
try await withCheckedThrowingContinuation { continuation in  
    healthStore.requestAuthorization(toShare: nil, read: typesToRead) { success, error in  
        if let error = error {  
            continuation.resume(throwing: error)  
        } else if success {  
            continuation.resume()  
        } else {  
            continuation.resume(throwing: WorkoutDataError.authorizationFailed)  
        }  
    }  
}   } ```swift

Then fetch the heart rate samples for a workout:

swift func fetchHeartRateData(for workout: HKWorkout) async throws -> [HKQuantitySample] { ... } swift

We’ll map each sample’s timestamp to a segment of the GPS route in a moment.

⏱️ Timestamp Sync is Everything

This is where it gets tricky.

Heart rate and GPS data come in at different rates and aren’t guaranteed to line up perfectly. We correlate them using timestamps and the route’s recorded start/end times.

Each HeartRateSample struct looks like this:

```swift struct HeartRateSample: Identifiable, Equatable {
let id = UUID()
let heartRate: Double
let timestamp: Date
let routeIndex: Int?

var zone: WorkoutHeartRateZone {  
    WorkoutHeartRateZone.from(heartRate: heartRate)  
}   } ```swift

🧩 Segment Generation Strategy

To keep MapKit rendering smooth, we break the route into about 50 segments:

swift let segmentSize = max(2, routeCoordinates.count / 50) swift

For each chunk, we find the average heart rate in that time range and map it to a zone + color:

swift let zone = WorkoutHeartRateZone.from(heartRate: avgHR) let color = interpolateHeartRateColor(heartRate: avgHR, zone: zone) swift

🎨 Color Interpolation That Doesn’t Look Like Trash

We scale the color intensity based on how deep you are in a zone. A light orange isn’t the same as a full-blast red. This is how we add that feeling of intensity:

swift private static func interpolateHeartRateColor(heartRate: Double, zone: WorkoutHeartRateZone) -> Color { let range = zone.range let progress = (heartRate - range.lowerBound) / (range.upperBound - range.lowerBound) let opacity = max(0.4, min(1.0, 0.4 + (progress * 0.6))) return zone.color.opacity(opacity) } swift

🗺️ MapKit Overlay Rendering

This is where it all comes together. Using MapKit in SwiftUI, we render the polyline segments:

swift MapPolyline(coordinates: segment.coordinates) .stroke( segment.color, style: StrokeStyle( lineWidth: 8, lineCap: .round, lineJoin: .round ) ) swift

Boom. Now you can see where you were flying and where your heart nearly quit.

🧰 SwiftUI Controls + Context Menus

Add a little UI to toggle the heatmap, present a selection sheet, or pick different heatmap types:

swift .sheet(isPresented: $heatMapState.isSelectionSheetPresented) { HeatMapSelectionSheet(heatMapState: heatMapState) } .contextMenu { Button("Heart Rate") { heatMapState.selectType(.heartRate) } } swift

Use a simple animated button to flip the heat-map on/off with feedback:

swift AnimatedButton { heatMapState.toggleHeatMap() } label: { Image(systemName: "map.fill") } swift

🎛️ State Management with @Observable

Keep everything reactive and Swift 6–ready with this:

```swift @Observable
@MainActor
final class HeatMapState {
var isEnabled = false
var currentType: HeatMapType = .heartRate
var showLegend = true
var isSelectionSheetPresented = false

func toggleHeatMap() {  
    isEnabled.toggle()  
}  
  
func selectType(_ type: HeatMapType) {  
    currentType = type  
    isEnabled = true  
}  
  
func reset() { ... }   } ```swift

🛠️ What’s Next: Cadence, Elevation, and WorkoutRoutes

This architecture isn’t just for heart rate. With minimal tweaks, you can extend this system to:

  • Running cadence
  • Elevation changes
  • Pace consistency
  • Even stress zones (with HRV)

All of this is going into my next app, WorkoutRoutes. It’s built for runners, walkers, cyclist, or who want more than just “how far” and “how fast.” I want to show them how their body felt.

Stay tuned for that.

🎯 Bonus: More Real-World iOS Survival Stories

If you’re hungry for more tips, tricks, and battle-tested SwiftUI stories, swing by my full collection: https://medium.com/@wesleymatlock.

These posts are packed with real-world solutions, some laughs, and the kind of Swift code that doesn’t scream in Instruments. Let’s keep building apps that rock — and if you’ve got stories of your own, drop me a line. I’d love to hear from you.

👊 Wes


Originally published on Medium