import { type GameDefinition } from '../core/gameDefinition' import { DEFAULT_TELEMETRY_CONFIG, getHeartRateToneLabel, getHeartRateToneRangeText, getSpeedToneRangeText, mergeTelemetryConfig, type HeartRateTone, type TelemetryConfig, } from './telemetryConfig' import { type GameSessionState } from '../core/gameSessionState' import { type TelemetryEvent } from './telemetryEvent' import { EMPTY_TELEMETRY_PRESENTATION, type TelemetryPresentation } from './telemetryPresentation' import { EMPTY_TELEMETRY_STATE, type DevicePose, type HeadingConfidence, type TelemetryState, } from './telemetryState' const SPEED_SMOOTHING_ALPHA = 0.35 const DEVICE_HEADING_SMOOTHING_ALPHA = 0.28 const ACCELEROMETER_SMOOTHING_ALPHA = 0.2 const DEVICE_POSE_FLAT_ENTER_Z = 0.82 const DEVICE_POSE_FLAT_EXIT_Z = 0.7 const DEVICE_POSE_UPRIGHT_ENTER_Z = 0.42 const DEVICE_POSE_UPRIGHT_EXIT_Z = 0.55 const DEVICE_POSE_UPRIGHT_AXIS_ENTER = 0.78 const DEVICE_POSE_UPRIGHT_AXIS_EXIT = 0.65 const HEADING_CONFIDENCE_HIGH_TURN_RATE_RAD = 0.35 const HEADING_CONFIDENCE_MEDIUM_TURN_RATE_RAD = 1.05 function normalizeHeadingDeg(headingDeg: number): number { const normalized = headingDeg % 360 return normalized < 0 ? normalized + 360 : normalized } function normalizeHeadingDeltaDeg(deltaDeg: number): number { let normalized = deltaDeg while (normalized > 180) { normalized -= 360 } while (normalized < -180) { normalized += 360 } return normalized } function interpolateHeadingDeg(currentDeg: number, targetDeg: number, factor: number): number { return normalizeHeadingDeg(currentDeg + normalizeHeadingDeltaDeg(targetDeg - currentDeg) * factor) } function resolveMotionCompassHeadingDeg( alpha: number | null, beta: number | null, gamma: number | null, ): number | null { if (alpha === null) { return null } if (beta === null || gamma === null) { return normalizeHeadingDeg(360 - alpha) } const alphaRad = alpha * Math.PI / 180 const betaRad = beta * Math.PI / 180 const gammaRad = gamma * Math.PI / 180 const cA = Math.cos(alphaRad) const sA = Math.sin(alphaRad) const sB = Math.sin(betaRad) const cG = Math.cos(gammaRad) const sG = Math.sin(gammaRad) const headingX = -cA * sG - sA * sB * cG const headingY = -sA * sG + cA * sB * cG if (Math.abs(headingX) < 1e-6 && Math.abs(headingY) < 1e-6) { return normalizeHeadingDeg(360 - alpha) } let headingRad = Math.atan2(headingX, headingY) if (headingRad < 0) { headingRad += Math.PI * 2 } return normalizeHeadingDeg(headingRad * 180 / Math.PI) } function getApproxDistanceMeters( a: { lon: number; lat: number }, b: { lon: number; lat: number }, ): number { const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180 const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad) const dy = (b.lat - a.lat) * 110540 return Math.sqrt(dx * dx + dy * dy) } function formatElapsedTimerText(totalMs: number): string { const safeMs = Math.max(0, totalMs) const totalSeconds = Math.floor(safeMs / 1000) const hours = Math.floor(totalSeconds / 3600) const minutes = Math.floor((totalSeconds % 3600) / 60) const seconds = totalSeconds % 60 return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` } function formatDistanceText(distanceMeters: number): string { if (distanceMeters >= 1000) { return `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}km` } return `${Math.round(distanceMeters)}m` } function formatTargetDistance(distanceMeters: number | null): { valueText: string; unitText: string } { if (distanceMeters === null) { return { valueText: '--', unitText: '', } } return distanceMeters >= 1000 ? { valueText: `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}`, unitText: 'km', } : { valueText: String(Math.round(distanceMeters)), unitText: 'm', } } function formatSpeedText(speedKmh: number | null): string { if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 0.05) { return '0' } return speedKmh >= 10 ? speedKmh.toFixed(1) : speedKmh.toFixed(2) } function smoothSpeedKmh(previousSpeedKmh: number | null, nextSpeedKmh: number): number { if (previousSpeedKmh === null || !Number.isFinite(previousSpeedKmh)) { return nextSpeedKmh } return previousSpeedKmh + (nextSpeedKmh - previousSpeedKmh) * SPEED_SMOOTHING_ALPHA } function resolveDevicePose( previousPose: DevicePose, accelerometer: TelemetryState['accelerometer'], ): DevicePose { if (!accelerometer) { return previousPose } const magnitude = Math.sqrt( accelerometer.x * accelerometer.x + accelerometer.y * accelerometer.y + accelerometer.z * accelerometer.z, ) if (!Number.isFinite(magnitude) || magnitude <= 0.001) { return previousPose } const normalizedX = Math.abs(accelerometer.x / magnitude) const normalizedY = Math.abs(accelerometer.y / magnitude) const normalizedZ = Math.abs(accelerometer.z / magnitude) const verticalAxis = Math.max(normalizedX, normalizedY) const withinFlatEnter = normalizedZ >= DEVICE_POSE_FLAT_ENTER_Z const withinFlatExit = normalizedZ >= DEVICE_POSE_FLAT_EXIT_Z const withinUprightEnter = normalizedZ <= DEVICE_POSE_UPRIGHT_ENTER_Z && verticalAxis >= DEVICE_POSE_UPRIGHT_AXIS_ENTER const withinUprightExit = normalizedZ <= DEVICE_POSE_UPRIGHT_EXIT_Z && verticalAxis >= DEVICE_POSE_UPRIGHT_AXIS_EXIT if (previousPose === 'flat') { if (withinFlatExit) { return 'flat' } if (withinUprightEnter) { return 'upright' } return 'tilted' } if (previousPose === 'upright') { if (withinUprightExit) { return 'upright' } if (withinFlatEnter) { return 'flat' } return 'tilted' } if (withinFlatEnter) { return 'flat' } if (withinUprightEnter) { return 'upright' } return 'tilted' } function resolveHeadingConfidence( headingDeg: number | null, pose: DevicePose, gyroscope: TelemetryState['gyroscope'], ): HeadingConfidence { if (headingDeg === null || pose === 'flat') { return 'low' } if (!gyroscope) { return pose === 'upright' ? 'medium' : 'low' } const turnRate = Math.sqrt( gyroscope.x * gyroscope.x + gyroscope.y * gyroscope.y + gyroscope.z * gyroscope.z, ) if (turnRate <= HEADING_CONFIDENCE_HIGH_TURN_RATE_RAD) { return pose === 'upright' ? 'high' : 'medium' } if (turnRate <= HEADING_CONFIDENCE_MEDIUM_TURN_RATE_RAD) { return 'medium' } return 'low' } function getHeartRateTone( heartRateBpm: number | null, telemetryConfig: TelemetryConfig, ): HeartRateTone { if (heartRateBpm === null || !Number.isFinite(heartRateBpm) || heartRateBpm <= 0) { return 'blue' } const maxHeartRate = Math.max(120, 220 - telemetryConfig.heartRateAge) const restingHeartRate = Math.min(maxHeartRate - 15, telemetryConfig.restingHeartRateBpm) const reserve = Math.max(20, maxHeartRate - restingHeartRate) const blueLimit = restingHeartRate + reserve * 0.39 const purpleLimit = restingHeartRate + reserve * 0.54 const greenLimit = restingHeartRate + reserve * 0.69 const yellowLimit = restingHeartRate + reserve * 0.79 const orangeLimit = restingHeartRate + reserve * 0.89 if (heartRateBpm <= blueLimit) { return 'blue' } if (heartRateBpm <= purpleLimit) { return 'purple' } if (heartRateBpm <= greenLimit) { return 'green' } if (heartRateBpm <= yellowLimit) { return 'yellow' } if (heartRateBpm <= orangeLimit) { return 'orange' } return 'red' } function getSpeedFallbackTone(speedKmh: number | null): HeartRateTone { if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 3.2) { return 'blue' } if (speedKmh <= 4.0) { return 'purple' } if (speedKmh <= 5.5) { return 'green' } if (speedKmh <= 7.1) { return 'yellow' } if (speedKmh <= 8.8) { return 'orange' } return 'red' } function formatHeartRateMetric(heartRateBpm: number | null): { valueText: string; unitText: string } { if (heartRateBpm === null || !Number.isFinite(heartRateBpm) || heartRateBpm <= 0) { return { valueText: '--', unitText: '', } } return { valueText: String(Math.round(heartRateBpm)), unitText: 'bpm', } } function formatCaloriesMetric(caloriesKcal: number | null): { valueText: string; unitText: string } { if (caloriesKcal === null || !Number.isFinite(caloriesKcal) || caloriesKcal < 0) { return { valueText: '0', unitText: 'kcal', } } return { valueText: String(Math.round(caloriesKcal)), unitText: 'kcal', } } function formatAccuracyMetric(accuracyMeters: number | null): { valueText: string; unitText: string } { if (accuracyMeters === null || !Number.isFinite(accuracyMeters) || accuracyMeters < 0) { return { valueText: '--', unitText: '', } } return { valueText: String(Math.round(accuracyMeters)), unitText: 'm', } } function estimateCaloriesKcal( elapsedMs: number, heartRateBpm: number, telemetryConfig: TelemetryConfig, ): number { if (elapsedMs <= 0) { return 0 } if (!Number.isFinite(heartRateBpm) || heartRateBpm <= 0) { return 0 } const maxHeartRate = Math.max(120, 220 - telemetryConfig.heartRateAge) const restingHeartRate = Math.min(maxHeartRate - 15, telemetryConfig.restingHeartRateBpm) const reserve = Math.max(20, maxHeartRate - restingHeartRate) const intensity = Math.max(0, Math.min(1, (heartRateBpm - restingHeartRate) / reserve)) const met = 2 + intensity * 10 return met * telemetryConfig.userWeightKg * (elapsedMs / 3600000) } function estimateCaloriesFromSpeedKcal( elapsedMs: number, speedKmh: number | null, telemetryConfig: TelemetryConfig, ): number { if (elapsedMs <= 0 || speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 0.5) { return 0 } let met = 2 if (speedKmh >= 8.9) { met = 9.8 } else if (speedKmh >= 7.2) { met = 7.8 } else if (speedKmh >= 5.6) { met = 6 } else if (speedKmh >= 4.1) { met = 4.3 } else if (speedKmh >= 3.2) { met = 3.0 } return (met * 3.5 * telemetryConfig.userWeightKg / 200) * (elapsedMs / 60000) } function hasHeartRateSignal(state: TelemetryState): boolean { return state.heartRateBpm !== null && Number.isFinite(state.heartRateBpm) && state.heartRateBpm > 0 } function hasSpeedSignal(state: TelemetryState): boolean { return state.currentSpeedKmh !== null && Number.isFinite(state.currentSpeedKmh) && state.currentSpeedKmh >= 0.5 } function shouldTrackCalories(state: TelemetryState): boolean { return state.sessionStatus === 'running' && state.sessionEndedAt === null && (hasHeartRateSignal(state) || hasSpeedSignal(state)) } export class TelemetryRuntime { state: TelemetryState config: TelemetryConfig constructor() { this.state = { ...EMPTY_TELEMETRY_STATE } this.config = { ...DEFAULT_TELEMETRY_CONFIG } } reset(): void { this.state = { ...EMPTY_TELEMETRY_STATE, accelerometer: this.state.accelerometer, accelerometerUpdatedAt: this.state.accelerometerUpdatedAt, accelerometerSampleCount: this.state.accelerometerSampleCount, gyroscope: this.state.gyroscope, deviceMotion: this.state.deviceMotion, deviceHeadingDeg: this.state.deviceHeadingDeg, devicePose: this.state.devicePose, headingConfidence: this.state.headingConfidence, } } configure(config?: Partial | null): void { this.config = mergeTelemetryConfig(config) } loadDefinition(_definition: GameDefinition): void { this.reset() } syncGameState(definition: GameDefinition | null, state: GameSessionState | null, hudTargetControlId?: string | null): void { if (!definition || !state) { this.dispatch({ type: 'reset' }) return } const targetControlId = hudTargetControlId !== undefined ? hudTargetControlId : state.currentTargetControlId const targetControl = targetControlId ? definition.controls.find((control) => control.id === targetControlId) || null : null this.dispatch({ type: 'session_state_updated', at: Date.now(), status: state.status, startedAt: state.startedAt, endedAt: state.endedAt, }) this.dispatch({ type: 'target_updated', controlId: targetControl ? targetControl.id : null, point: targetControl ? targetControl.point : null, }) } dispatch(event: TelemetryEvent): void { if (event.type === 'reset') { this.reset() return } if (event.type === 'session_state_updated') { this.syncCalorieAccumulation(event.at) this.state = { ...this.state, sessionStatus: event.status, sessionStartedAt: event.startedAt, sessionEndedAt: event.endedAt, elapsedMs: event.startedAt === null ? 0 : Math.max(0, (event.endedAt || Date.now()) - event.startedAt), } this.alignCalorieTracking(event.at) this.recomputeDerivedState() return } if (event.type === 'target_updated') { this.state = { ...this.state, targetControlId: event.controlId, targetPoint: event.point, } this.recomputeDerivedState() return } if (event.type === 'gps_updated') { this.syncCalorieAccumulation(event.at) const nextPoint = { lon: event.lon, lat: event.lat } const previousPoint = this.state.lastGpsPoint const previousAt = this.state.lastGpsAt let nextDistanceMeters = this.state.distanceMeters let nextSpeedKmh = this.state.currentSpeedKmh if (previousPoint && previousAt !== null && event.at > previousAt) { const segmentMeters = getApproxDistanceMeters(previousPoint, nextPoint) nextDistanceMeters += segmentMeters const rawSpeedKmh = segmentMeters <= 0 ? 0 : (segmentMeters / ((event.at - previousAt) / 1000)) * 3.6 nextSpeedKmh = smoothSpeedKmh(this.state.currentSpeedKmh, rawSpeedKmh) } this.state = { ...this.state, distanceMeters: nextDistanceMeters, currentSpeedKmh: nextSpeedKmh, lastGpsPoint: nextPoint, lastGpsAt: event.at, lastGpsAccuracyMeters: event.accuracyMeters, } this.alignCalorieTracking(event.at) this.recomputeDerivedState() return } if (event.type === 'accelerometer_updated') { const previous = this.state.accelerometer this.state = { ...this.state, accelerometer: previous === null ? { x: event.x, y: event.y, z: event.z, } : { x: previous.x + (event.x - previous.x) * ACCELEROMETER_SMOOTHING_ALPHA, y: previous.y + (event.y - previous.y) * ACCELEROMETER_SMOOTHING_ALPHA, z: previous.z + (event.z - previous.z) * ACCELEROMETER_SMOOTHING_ALPHA, }, accelerometerUpdatedAt: event.at, accelerometerSampleCount: this.state.accelerometerSampleCount + 1, } this.recomputeDerivedState() return } if (event.type === 'gyroscope_updated') { this.state = { ...this.state, gyroscope: { x: event.x, y: event.y, z: event.z, }, } this.recomputeDerivedState() return } if (event.type === 'device_motion_updated') { const motionHeadingDeg = resolveMotionCompassHeadingDeg(event.alpha, event.beta, event.gamma) const nextDeviceHeadingDeg = motionHeadingDeg === null ? this.state.deviceHeadingDeg : (() => { return this.state.deviceHeadingDeg === null ? motionHeadingDeg : interpolateHeadingDeg(this.state.deviceHeadingDeg, motionHeadingDeg, DEVICE_HEADING_SMOOTHING_ALPHA) })() this.state = { ...this.state, deviceMotion: { alpha: event.alpha, beta: event.beta, gamma: event.gamma, }, deviceHeadingDeg: nextDeviceHeadingDeg, } this.recomputeDerivedState() return } if (event.type === 'heart_rate_updated') { this.syncCalorieAccumulation(event.at) this.state = { ...this.state, heartRateBpm: event.bpm, } this.alignCalorieTracking(event.at) this.recomputeDerivedState() } } recomputeDerivedState(now = Date.now()): void { const elapsedMs = this.state.sessionStartedAt === null ? 0 : Math.max(0, (this.state.sessionEndedAt || now) - this.state.sessionStartedAt) const distanceToTargetMeters = this.state.lastGpsPoint && this.state.targetPoint ? getApproxDistanceMeters(this.state.lastGpsPoint, this.state.targetPoint) : null const averageSpeedKmh = elapsedMs > 0 ? (this.state.distanceMeters / (elapsedMs / 1000)) * 3.6 : null const devicePose = resolveDevicePose(this.state.devicePose, this.state.accelerometer) const headingConfidence = resolveHeadingConfidence( this.state.deviceHeadingDeg, devicePose, this.state.gyroscope, ) this.state = { ...this.state, elapsedMs, distanceToTargetMeters, averageSpeedKmh, devicePose, headingConfidence, } } getPresentation(now = Date.now()): TelemetryPresentation { this.syncCalorieAccumulation(now) this.alignCalorieTracking(now) this.recomputeDerivedState(now) const targetDistance = formatTargetDistance(this.state.distanceToTargetMeters) const hasHeartRate = hasHeartRateSignal(this.state) const heartRateTone = hasHeartRate ? getHeartRateTone(this.state.heartRateBpm, this.config) : getSpeedFallbackTone(this.state.currentSpeedKmh) const heartRate = formatHeartRateMetric(this.state.heartRateBpm) const calories = formatCaloriesMetric(this.state.caloriesKcal) const accuracy = formatAccuracyMetric(this.state.lastGpsAccuracyMeters) return { ...EMPTY_TELEMETRY_PRESENTATION, timerText: formatElapsedTimerText(this.state.elapsedMs), mileageText: formatDistanceText(this.state.distanceMeters), distanceToTargetValueText: targetDistance.valueText, distanceToTargetUnitText: targetDistance.unitText, speedText: formatSpeedText(this.state.currentSpeedKmh), heartRateTone, heartRateZoneNameText: hasHeartRate || hasSpeedSignal(this.state) ? getHeartRateToneLabel(heartRateTone) : '--', heartRateZoneRangeText: hasHeartRate ? getHeartRateToneRangeText(heartRateTone) : hasSpeedSignal(this.state) ? getSpeedToneRangeText(heartRateTone) : '', heartRateValueText: heartRate.valueText, heartRateUnitText: heartRate.unitText, caloriesValueText: calories.valueText, caloriesUnitText: calories.unitText, averageSpeedValueText: formatSpeedText(this.state.averageSpeedKmh), averageSpeedUnitText: 'km/h', accuracyValueText: accuracy.valueText, accuracyUnitText: accuracy.unitText, } } private syncCalorieAccumulation(now: number): void { if (!shouldTrackCalories(this.state)) { return } if (this.state.calorieTrackingAt === null) { this.state = { ...this.state, calorieTrackingAt: now, caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal, } return } if (now <= this.state.calorieTrackingAt) { return } const deltaMs = now - this.state.calorieTrackingAt const calorieDelta = hasHeartRateSignal(this.state) ? estimateCaloriesKcal(deltaMs, this.state.heartRateBpm as number, this.config) : estimateCaloriesFromSpeedKcal(deltaMs, this.state.currentSpeedKmh, this.config) this.state = { ...this.state, calorieTrackingAt: now, caloriesKcal: (this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal) + calorieDelta, } } private alignCalorieTracking(now: number): void { if (shouldTrackCalories(this.state)) { if (this.state.calorieTrackingAt === null) { this.state = { ...this.state, calorieTrackingAt: now, caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal, } } return } if (this.state.calorieTrackingAt !== null) { this.state = { ...this.state, calorieTrackingAt: null, caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal, } } } }