Разработка
Создание тепловой карты пульса на маршруте с помощью SwiftUI + HealthKit
Вот о чём этот пост: как превратить плоскую статистику в полноцветное повествование. Мы создаём тепловую карту на базе MapKit, используя данные пульса HealthKit, и синхронизируем их с вашим GPS-маршрутом, чтобы точно показать, где вы отдыхали (синий), а где были на грани потери сознания (красным).
Большинство беговых приложений показывают среднюю частоту пульса. «Молодец, пульс был 148 ударов в минуту». Отлично. Но где во время пробежки у вас начались проблемы? Где ваше тело было перегружено?
Вот о чём этот пост: как превратить плоскую статистику в полноцветное повествование. Мы создаём тепловую карту на базе MapKit, используя данные пульса HealthKit, и синхронизируем их с вашим GPS-маршрутом, чтобы точно показать, где вы отдыхали (синий), а где были на грани потери сознания (красным).
Эта система — ключевой элемент нового приложения, над которым я работаю, под названием WorkoutRoutes — подробнее о нём позже. Сейчас мы сосредоточены на самом главном: точной синхронизации с HealthKit, понятном интерфейсе SwiftUI и быстром рендеринге с примерно 50 интеллектуальными сегментами.
Проблема средних показателей
Одно число не может отразить ваши усилия с течением времени. Вы можете спускаться с горы с частотой пульса 130 ударов в минуту, а затем достичь 190 ударов в минуту на последнем подъеме, но приложение покажет только «среднее значение: 150».
Нам нужен контекст. А это значит, что нужно сопоставить ваш пульс с данными GPS с учетом времени.
Зоны сердечного ритма: основы
Для начала определите свои зоны сердечного ритма. Вот простая система, которую мы используем:
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
}
}
Авторизация и получение данных в HealthKit
HealthKit предоставляет нам данные о частоте сердечных сокращений и маршруты GPS, но они находятся в отдельных API. Сначала запросите доступ:
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)
}
}
}
}
Затем извлеките данные сердечного ритма для тренировки:
func fetchHeartRateData(for workout: HKWorkout) async throws -> [HKQuantitySample] { ... }
Скоро мы сопоставим временную метку каждого образца с сегментом маршрута GPS.
Синхронизация временных меток — это главное
Вот тут-то и начинается сложность.
Данные сердечного ритма и GPS поступают с разной частотой и не гарантируют идеального соответствия. Мы сопоставляем их, используя временные метки и записанное время начала/конца маршрута.
Каждая структура HeartRateSample выглядит следующим образом:
struct HeartRateSample: Identifiable, Equatable {
let id = UUID()
let heartRate: Double
let timestamp: Date
let routeIndex: Int?
var zone: WorkoutHeartRateZone {
WorkoutHeartRateZone.from(heartRate: heartRate)
}
}
Стратегия генерации сегментов
Чтобы обеспечить плавность рендеринга MapKit, мы разбиваем маршрут примерно на 50 сегментов:
let segmentSize = max(2, routeCoordinates.count / 50)
Для каждого фрагмента мы находим среднюю частоту сердечных сокращений в этом диапазоне времени и сопоставляем ее с зоной + цветом:
let zone = WorkoutHeartRateZone.from(heartRate: avgHR) let color = interpolateHeartRateColor(heartRate: avgHR, zone: zone)
Интерполяция цвета, которая выглядит хорошо
Мы изменяем интенсивность цвета в зависимости от того, насколько глубоко вы находитесь в зоне. Светло-оранжевый — это не то же самое, что ярко-красный. Вот как мы добавляем ощущение интенсивности:
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)
}
Рендеринг оверлея MapKit
Вот где всё сходится. Используя MapKit в SwiftUI, мы рендерим сегменты:
MapPolyline(coordinates: segment.coordinates)
.stroke(
segment.color,
style: StrokeStyle(
lineWidth: 8,
lineCap: .round,
lineJoin: .round
)
)
Бум! Теперь вы видите, где мчались и где ваше сердце чуть не остановилось.
Элементы управления SwiftUI + контекстные меню
Добавьте небольшой пользовательский интерфейс для переключения тепловой карты, отображения экрана выбора или выбора различных типов тепловой карты:
.sheet(isPresented: $heatMapState.isSelectionSheetPresented) {
HeatMapSelectionSheet(heatMapState: heatMapState)
}
.contextMenu {
Button("Heart Rate") { heatMapState.selectType(.heartRate) }
}
Используйте простую анимированную кнопку для включения/выключения тепловой карты с обратной связью:
AnimatedButton {
heatMapState.toggleHeatMap()
} label: {
Image(systemName: "map.fill")
}
Управление состоянием с помощью @Observable
Держите всё реактивным и готовым к Swift 6 с помощью этого:
@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() { ... }
}
Что дальше: каденс, высота и WorkoutRoutes
Эта архитектура предназначена не только для измерения пульса. С минимальными изменениями вы можете расширить эту систему для:
- каденса бега
- изменения высоты
- постоянства темпа
- даже определения зон стресса (с HRV)
Всё это войдет в мое следующее приложение WorkoutRoutes. Оно создано для бегунов, пешеходов, велосипедистов и всех тех, кому нужно больше, чем просто «как далеко» и «как быстро». Я хочу показать им, как себя чувствует их тело.
Следите за новостями.
-
Аналитика магазинов4 недели назад
Мобильный рынок Ближнего Востока: исследование Bidease и Sensor Tower выявляет драйверы роста
-
Видео и подкасты для разработчиков4 недели назад
Разбор кода: iOS-приложение для управления личными финансами на Swift. Часть 1
-
Новости4 недели назад
Видео и подкасты о мобильной разработке 2025.47
-
Разработка4 недели назад
100 уроков о том, как я довёл своё приложение до продажи за семизначную сумму


