Connect with us

Разработка

Создание тепловой карты пульса на маршруте с помощью 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 + HealthKit

Элементы управления 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. Оно создано для бегунов, пешеходов, велосипедистов и всех тех, кому нужно больше, чем просто «как далеко» и «как быстро». Я хочу показать им, как себя чувствует их тело.

Следите за новостями.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: