telemetryRuntime.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719
  1. import { type GameDefinition } from '../core/gameDefinition'
  2. import {
  3. DEFAULT_TELEMETRY_CONFIG,
  4. getHeartRateToneLabel,
  5. getHeartRateToneRangeText,
  6. getSpeedToneRangeText,
  7. mergeTelemetryConfig,
  8. type HeartRateTone,
  9. type TelemetryConfig,
  10. } from './telemetryConfig'
  11. import { type GameSessionState } from '../core/gameSessionState'
  12. import { type TelemetryEvent } from './telemetryEvent'
  13. import { EMPTY_TELEMETRY_PRESENTATION, type TelemetryPresentation } from './telemetryPresentation'
  14. import {
  15. EMPTY_TELEMETRY_STATE,
  16. type DevicePose,
  17. type HeadingConfidence,
  18. type TelemetryState,
  19. } from './telemetryState'
  20. const SPEED_SMOOTHING_ALPHA = 0.35
  21. const DEVICE_HEADING_SMOOTHING_ALPHA = 0.28
  22. const ACCELEROMETER_SMOOTHING_ALPHA = 0.2
  23. const DEVICE_POSE_FLAT_ENTER_Z = 0.82
  24. const DEVICE_POSE_FLAT_EXIT_Z = 0.7
  25. const DEVICE_POSE_UPRIGHT_ENTER_Z = 0.42
  26. const DEVICE_POSE_UPRIGHT_EXIT_Z = 0.55
  27. const DEVICE_POSE_UPRIGHT_AXIS_ENTER = 0.78
  28. const DEVICE_POSE_UPRIGHT_AXIS_EXIT = 0.65
  29. const HEADING_CONFIDENCE_HIGH_TURN_RATE_RAD = 0.35
  30. const HEADING_CONFIDENCE_MEDIUM_TURN_RATE_RAD = 1.05
  31. function normalizeHeadingDeg(headingDeg: number): number {
  32. const normalized = headingDeg % 360
  33. return normalized < 0 ? normalized + 360 : normalized
  34. }
  35. function normalizeHeadingDeltaDeg(deltaDeg: number): number {
  36. let normalized = deltaDeg
  37. while (normalized > 180) {
  38. normalized -= 360
  39. }
  40. while (normalized < -180) {
  41. normalized += 360
  42. }
  43. return normalized
  44. }
  45. function interpolateHeadingDeg(currentDeg: number, targetDeg: number, factor: number): number {
  46. return normalizeHeadingDeg(currentDeg + normalizeHeadingDeltaDeg(targetDeg - currentDeg) * factor)
  47. }
  48. function resolveMotionCompassHeadingDeg(
  49. alpha: number | null,
  50. beta: number | null,
  51. gamma: number | null,
  52. ): number | null {
  53. if (alpha === null) {
  54. return null
  55. }
  56. if (beta === null || gamma === null) {
  57. return normalizeHeadingDeg(360 - alpha)
  58. }
  59. const alphaRad = alpha * Math.PI / 180
  60. const betaRad = beta * Math.PI / 180
  61. const gammaRad = gamma * Math.PI / 180
  62. const cA = Math.cos(alphaRad)
  63. const sA = Math.sin(alphaRad)
  64. const sB = Math.sin(betaRad)
  65. const cG = Math.cos(gammaRad)
  66. const sG = Math.sin(gammaRad)
  67. const headingX = -cA * sG - sA * sB * cG
  68. const headingY = -sA * sG + cA * sB * cG
  69. if (Math.abs(headingX) < 1e-6 && Math.abs(headingY) < 1e-6) {
  70. return normalizeHeadingDeg(360 - alpha)
  71. }
  72. let headingRad = Math.atan2(headingX, headingY)
  73. if (headingRad < 0) {
  74. headingRad += Math.PI * 2
  75. }
  76. return normalizeHeadingDeg(headingRad * 180 / Math.PI)
  77. }
  78. function getApproxDistanceMeters(
  79. a: { lon: number; lat: number },
  80. b: { lon: number; lat: number },
  81. ): number {
  82. const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
  83. const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
  84. const dy = (b.lat - a.lat) * 110540
  85. return Math.sqrt(dx * dx + dy * dy)
  86. }
  87. function formatElapsedTimerText(totalMs: number): string {
  88. const safeMs = Math.max(0, totalMs)
  89. const totalSeconds = Math.floor(safeMs / 1000)
  90. const hours = Math.floor(totalSeconds / 3600)
  91. const minutes = Math.floor((totalSeconds % 3600) / 60)
  92. const seconds = totalSeconds % 60
  93. return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
  94. }
  95. function formatDistanceText(distanceMeters: number): string {
  96. if (distanceMeters >= 1000) {
  97. return `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}km`
  98. }
  99. return `${Math.round(distanceMeters)}m`
  100. }
  101. function formatTargetDistance(distanceMeters: number | null): { valueText: string; unitText: string } {
  102. if (distanceMeters === null) {
  103. return {
  104. valueText: '--',
  105. unitText: '',
  106. }
  107. }
  108. return distanceMeters >= 1000
  109. ? {
  110. valueText: `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}`,
  111. unitText: 'km',
  112. }
  113. : {
  114. valueText: String(Math.round(distanceMeters)),
  115. unitText: 'm',
  116. }
  117. }
  118. function formatSpeedText(speedKmh: number | null): string {
  119. if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 0.05) {
  120. return '0'
  121. }
  122. return speedKmh >= 10 ? speedKmh.toFixed(1) : speedKmh.toFixed(2)
  123. }
  124. function smoothSpeedKmh(previousSpeedKmh: number | null, nextSpeedKmh: number): number {
  125. if (previousSpeedKmh === null || !Number.isFinite(previousSpeedKmh)) {
  126. return nextSpeedKmh
  127. }
  128. return previousSpeedKmh + (nextSpeedKmh - previousSpeedKmh) * SPEED_SMOOTHING_ALPHA
  129. }
  130. function resolveDevicePose(
  131. previousPose: DevicePose,
  132. accelerometer: TelemetryState['accelerometer'],
  133. ): DevicePose {
  134. if (!accelerometer) {
  135. return previousPose
  136. }
  137. const magnitude = Math.sqrt(
  138. accelerometer.x * accelerometer.x
  139. + accelerometer.y * accelerometer.y
  140. + accelerometer.z * accelerometer.z,
  141. )
  142. if (!Number.isFinite(magnitude) || magnitude <= 0.001) {
  143. return previousPose
  144. }
  145. const normalizedX = Math.abs(accelerometer.x / magnitude)
  146. const normalizedY = Math.abs(accelerometer.y / magnitude)
  147. const normalizedZ = Math.abs(accelerometer.z / magnitude)
  148. const verticalAxis = Math.max(normalizedX, normalizedY)
  149. const withinFlatEnter = normalizedZ >= DEVICE_POSE_FLAT_ENTER_Z
  150. const withinFlatExit = normalizedZ >= DEVICE_POSE_FLAT_EXIT_Z
  151. const withinUprightEnter = normalizedZ <= DEVICE_POSE_UPRIGHT_ENTER_Z && verticalAxis >= DEVICE_POSE_UPRIGHT_AXIS_ENTER
  152. const withinUprightExit = normalizedZ <= DEVICE_POSE_UPRIGHT_EXIT_Z && verticalAxis >= DEVICE_POSE_UPRIGHT_AXIS_EXIT
  153. if (previousPose === 'flat') {
  154. if (withinFlatExit) {
  155. return 'flat'
  156. }
  157. if (withinUprightEnter) {
  158. return 'upright'
  159. }
  160. return 'tilted'
  161. }
  162. if (previousPose === 'upright') {
  163. if (withinUprightExit) {
  164. return 'upright'
  165. }
  166. if (withinFlatEnter) {
  167. return 'flat'
  168. }
  169. return 'tilted'
  170. }
  171. if (withinFlatEnter) {
  172. return 'flat'
  173. }
  174. if (withinUprightEnter) {
  175. return 'upright'
  176. }
  177. return 'tilted'
  178. }
  179. function resolveHeadingConfidence(
  180. headingDeg: number | null,
  181. pose: DevicePose,
  182. gyroscope: TelemetryState['gyroscope'],
  183. ): HeadingConfidence {
  184. if (headingDeg === null || pose === 'flat') {
  185. return 'low'
  186. }
  187. if (!gyroscope) {
  188. return pose === 'upright' ? 'medium' : 'low'
  189. }
  190. const turnRate = Math.sqrt(
  191. gyroscope.x * gyroscope.x
  192. + gyroscope.y * gyroscope.y
  193. + gyroscope.z * gyroscope.z,
  194. )
  195. if (turnRate <= HEADING_CONFIDENCE_HIGH_TURN_RATE_RAD) {
  196. return pose === 'upright' ? 'high' : 'medium'
  197. }
  198. if (turnRate <= HEADING_CONFIDENCE_MEDIUM_TURN_RATE_RAD) {
  199. return 'medium'
  200. }
  201. return 'low'
  202. }
  203. function getHeartRateTone(
  204. heartRateBpm: number | null,
  205. telemetryConfig: TelemetryConfig,
  206. ): HeartRateTone {
  207. if (heartRateBpm === null || !Number.isFinite(heartRateBpm) || heartRateBpm <= 0) {
  208. return 'blue'
  209. }
  210. const maxHeartRate = Math.max(120, 220 - telemetryConfig.heartRateAge)
  211. const restingHeartRate = Math.min(maxHeartRate - 15, telemetryConfig.restingHeartRateBpm)
  212. const reserve = Math.max(20, maxHeartRate - restingHeartRate)
  213. const blueLimit = restingHeartRate + reserve * 0.39
  214. const purpleLimit = restingHeartRate + reserve * 0.54
  215. const greenLimit = restingHeartRate + reserve * 0.69
  216. const yellowLimit = restingHeartRate + reserve * 0.79
  217. const orangeLimit = restingHeartRate + reserve * 0.89
  218. if (heartRateBpm <= blueLimit) {
  219. return 'blue'
  220. }
  221. if (heartRateBpm <= purpleLimit) {
  222. return 'purple'
  223. }
  224. if (heartRateBpm <= greenLimit) {
  225. return 'green'
  226. }
  227. if (heartRateBpm <= yellowLimit) {
  228. return 'yellow'
  229. }
  230. if (heartRateBpm <= orangeLimit) {
  231. return 'orange'
  232. }
  233. return 'red'
  234. }
  235. function getSpeedFallbackTone(speedKmh: number | null): HeartRateTone {
  236. if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 3.2) {
  237. return 'blue'
  238. }
  239. if (speedKmh <= 4.0) {
  240. return 'purple'
  241. }
  242. if (speedKmh <= 5.5) {
  243. return 'green'
  244. }
  245. if (speedKmh <= 7.1) {
  246. return 'yellow'
  247. }
  248. if (speedKmh <= 8.8) {
  249. return 'orange'
  250. }
  251. return 'red'
  252. }
  253. function formatHeartRateMetric(heartRateBpm: number | null): { valueText: string; unitText: string } {
  254. if (heartRateBpm === null || !Number.isFinite(heartRateBpm) || heartRateBpm <= 0) {
  255. return {
  256. valueText: '--',
  257. unitText: '',
  258. }
  259. }
  260. return {
  261. valueText: String(Math.round(heartRateBpm)),
  262. unitText: 'bpm',
  263. }
  264. }
  265. function formatCaloriesMetric(caloriesKcal: number | null): { valueText: string; unitText: string } {
  266. if (caloriesKcal === null || !Number.isFinite(caloriesKcal) || caloriesKcal < 0) {
  267. return {
  268. valueText: '0',
  269. unitText: 'kcal',
  270. }
  271. }
  272. return {
  273. valueText: String(Math.round(caloriesKcal)),
  274. unitText: 'kcal',
  275. }
  276. }
  277. function formatAccuracyMetric(accuracyMeters: number | null): { valueText: string; unitText: string } {
  278. if (accuracyMeters === null || !Number.isFinite(accuracyMeters) || accuracyMeters < 0) {
  279. return {
  280. valueText: '--',
  281. unitText: '',
  282. }
  283. }
  284. return {
  285. valueText: String(Math.round(accuracyMeters)),
  286. unitText: 'm',
  287. }
  288. }
  289. function estimateCaloriesKcal(
  290. elapsedMs: number,
  291. heartRateBpm: number,
  292. telemetryConfig: TelemetryConfig,
  293. ): number {
  294. if (elapsedMs <= 0) {
  295. return 0
  296. }
  297. if (!Number.isFinite(heartRateBpm) || heartRateBpm <= 0) {
  298. return 0
  299. }
  300. const maxHeartRate = Math.max(120, 220 - telemetryConfig.heartRateAge)
  301. const restingHeartRate = Math.min(maxHeartRate - 15, telemetryConfig.restingHeartRateBpm)
  302. const reserve = Math.max(20, maxHeartRate - restingHeartRate)
  303. const intensity = Math.max(0, Math.min(1, (heartRateBpm - restingHeartRate) / reserve))
  304. const met = 2 + intensity * 10
  305. return met * telemetryConfig.userWeightKg * (elapsedMs / 3600000)
  306. }
  307. function estimateCaloriesFromSpeedKcal(
  308. elapsedMs: number,
  309. speedKmh: number | null,
  310. telemetryConfig: TelemetryConfig,
  311. ): number {
  312. if (elapsedMs <= 0 || speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 0.5) {
  313. return 0
  314. }
  315. let met = 2
  316. if (speedKmh >= 8.9) {
  317. met = 9.8
  318. } else if (speedKmh >= 7.2) {
  319. met = 7.8
  320. } else if (speedKmh >= 5.6) {
  321. met = 6
  322. } else if (speedKmh >= 4.1) {
  323. met = 4.3
  324. } else if (speedKmh >= 3.2) {
  325. met = 3.0
  326. }
  327. return (met * 3.5 * telemetryConfig.userWeightKg / 200) * (elapsedMs / 60000)
  328. }
  329. function hasHeartRateSignal(state: TelemetryState): boolean {
  330. return state.heartRateBpm !== null
  331. && Number.isFinite(state.heartRateBpm)
  332. && state.heartRateBpm > 0
  333. }
  334. function hasSpeedSignal(state: TelemetryState): boolean {
  335. return state.currentSpeedKmh !== null
  336. && Number.isFinite(state.currentSpeedKmh)
  337. && state.currentSpeedKmh >= 0.5
  338. }
  339. function shouldTrackCalories(state: TelemetryState): boolean {
  340. return state.sessionStatus === 'running'
  341. && state.sessionEndedAt === null
  342. && (hasHeartRateSignal(state) || hasSpeedSignal(state))
  343. }
  344. export class TelemetryRuntime {
  345. state: TelemetryState
  346. config: TelemetryConfig
  347. constructor() {
  348. this.state = { ...EMPTY_TELEMETRY_STATE }
  349. this.config = { ...DEFAULT_TELEMETRY_CONFIG }
  350. }
  351. reset(): void {
  352. this.state = {
  353. ...EMPTY_TELEMETRY_STATE,
  354. accelerometer: this.state.accelerometer,
  355. accelerometerUpdatedAt: this.state.accelerometerUpdatedAt,
  356. accelerometerSampleCount: this.state.accelerometerSampleCount,
  357. gyroscope: this.state.gyroscope,
  358. deviceMotion: this.state.deviceMotion,
  359. deviceHeadingDeg: this.state.deviceHeadingDeg,
  360. devicePose: this.state.devicePose,
  361. headingConfidence: this.state.headingConfidence,
  362. }
  363. }
  364. configure(config?: Partial<TelemetryConfig> | null): void {
  365. this.config = mergeTelemetryConfig(config)
  366. }
  367. loadDefinition(_definition: GameDefinition): void {
  368. this.reset()
  369. }
  370. syncGameState(definition: GameDefinition | null, state: GameSessionState | null, hudTargetControlId?: string | null): void {
  371. if (!definition || !state) {
  372. this.dispatch({ type: 'reset' })
  373. return
  374. }
  375. const targetControlId = hudTargetControlId !== undefined ? hudTargetControlId : state.currentTargetControlId
  376. const targetControl = targetControlId
  377. ? definition.controls.find((control) => control.id === targetControlId) || null
  378. : null
  379. this.dispatch({
  380. type: 'session_state_updated',
  381. at: Date.now(),
  382. status: state.status,
  383. startedAt: state.startedAt,
  384. endedAt: state.endedAt,
  385. })
  386. this.dispatch({
  387. type: 'target_updated',
  388. controlId: targetControl ? targetControl.id : null,
  389. point: targetControl ? targetControl.point : null,
  390. })
  391. }
  392. dispatch(event: TelemetryEvent): void {
  393. if (event.type === 'reset') {
  394. this.reset()
  395. return
  396. }
  397. if (event.type === 'session_state_updated') {
  398. this.syncCalorieAccumulation(event.at)
  399. this.state = {
  400. ...this.state,
  401. sessionStatus: event.status,
  402. sessionStartedAt: event.startedAt,
  403. sessionEndedAt: event.endedAt,
  404. elapsedMs: event.startedAt === null ? 0 : Math.max(0, (event.endedAt || Date.now()) - event.startedAt),
  405. }
  406. this.alignCalorieTracking(event.at)
  407. this.recomputeDerivedState()
  408. return
  409. }
  410. if (event.type === 'target_updated') {
  411. this.state = {
  412. ...this.state,
  413. targetControlId: event.controlId,
  414. targetPoint: event.point,
  415. }
  416. this.recomputeDerivedState()
  417. return
  418. }
  419. if (event.type === 'gps_updated') {
  420. this.syncCalorieAccumulation(event.at)
  421. const nextPoint = { lon: event.lon, lat: event.lat }
  422. const previousPoint = this.state.lastGpsPoint
  423. const previousAt = this.state.lastGpsAt
  424. let nextDistanceMeters = this.state.distanceMeters
  425. let nextSpeedKmh = this.state.currentSpeedKmh
  426. if (previousPoint && previousAt !== null && event.at > previousAt) {
  427. const segmentMeters = getApproxDistanceMeters(previousPoint, nextPoint)
  428. nextDistanceMeters += segmentMeters
  429. const rawSpeedKmh = segmentMeters <= 0
  430. ? 0
  431. : (segmentMeters / ((event.at - previousAt) / 1000)) * 3.6
  432. nextSpeedKmh = smoothSpeedKmh(this.state.currentSpeedKmh, rawSpeedKmh)
  433. }
  434. this.state = {
  435. ...this.state,
  436. distanceMeters: nextDistanceMeters,
  437. currentSpeedKmh: nextSpeedKmh,
  438. lastGpsPoint: nextPoint,
  439. lastGpsAt: event.at,
  440. lastGpsAccuracyMeters: event.accuracyMeters,
  441. }
  442. this.alignCalorieTracking(event.at)
  443. this.recomputeDerivedState()
  444. return
  445. }
  446. if (event.type === 'accelerometer_updated') {
  447. const previous = this.state.accelerometer
  448. this.state = {
  449. ...this.state,
  450. accelerometer: previous === null
  451. ? {
  452. x: event.x,
  453. y: event.y,
  454. z: event.z,
  455. }
  456. : {
  457. x: previous.x + (event.x - previous.x) * ACCELEROMETER_SMOOTHING_ALPHA,
  458. y: previous.y + (event.y - previous.y) * ACCELEROMETER_SMOOTHING_ALPHA,
  459. z: previous.z + (event.z - previous.z) * ACCELEROMETER_SMOOTHING_ALPHA,
  460. },
  461. accelerometerUpdatedAt: event.at,
  462. accelerometerSampleCount: this.state.accelerometerSampleCount + 1,
  463. }
  464. this.recomputeDerivedState()
  465. return
  466. }
  467. if (event.type === 'gyroscope_updated') {
  468. this.state = {
  469. ...this.state,
  470. gyroscope: {
  471. x: event.x,
  472. y: event.y,
  473. z: event.z,
  474. },
  475. }
  476. this.recomputeDerivedState()
  477. return
  478. }
  479. if (event.type === 'device_motion_updated') {
  480. const motionHeadingDeg = resolveMotionCompassHeadingDeg(event.alpha, event.beta, event.gamma)
  481. const nextDeviceHeadingDeg = motionHeadingDeg === null
  482. ? this.state.deviceHeadingDeg
  483. : (() => {
  484. return this.state.deviceHeadingDeg === null
  485. ? motionHeadingDeg
  486. : interpolateHeadingDeg(this.state.deviceHeadingDeg, motionHeadingDeg, DEVICE_HEADING_SMOOTHING_ALPHA)
  487. })()
  488. this.state = {
  489. ...this.state,
  490. deviceMotion: {
  491. alpha: event.alpha,
  492. beta: event.beta,
  493. gamma: event.gamma,
  494. },
  495. deviceHeadingDeg: nextDeviceHeadingDeg,
  496. }
  497. this.recomputeDerivedState()
  498. return
  499. }
  500. if (event.type === 'heart_rate_updated') {
  501. this.syncCalorieAccumulation(event.at)
  502. this.state = {
  503. ...this.state,
  504. heartRateBpm: event.bpm,
  505. }
  506. this.alignCalorieTracking(event.at)
  507. this.recomputeDerivedState()
  508. }
  509. }
  510. recomputeDerivedState(now = Date.now()): void {
  511. const elapsedMs = this.state.sessionStartedAt === null
  512. ? 0
  513. : Math.max(0, (this.state.sessionEndedAt || now) - this.state.sessionStartedAt)
  514. const distanceToTargetMeters = this.state.lastGpsPoint && this.state.targetPoint
  515. ? getApproxDistanceMeters(this.state.lastGpsPoint, this.state.targetPoint)
  516. : null
  517. const averageSpeedKmh = elapsedMs > 0
  518. ? (this.state.distanceMeters / (elapsedMs / 1000)) * 3.6
  519. : null
  520. const devicePose = resolveDevicePose(this.state.devicePose, this.state.accelerometer)
  521. const headingConfidence = resolveHeadingConfidence(
  522. this.state.deviceHeadingDeg,
  523. devicePose,
  524. this.state.gyroscope,
  525. )
  526. this.state = {
  527. ...this.state,
  528. elapsedMs,
  529. distanceToTargetMeters,
  530. averageSpeedKmh,
  531. devicePose,
  532. headingConfidence,
  533. }
  534. }
  535. getPresentation(now = Date.now()): TelemetryPresentation {
  536. this.syncCalorieAccumulation(now)
  537. this.alignCalorieTracking(now)
  538. this.recomputeDerivedState(now)
  539. const targetDistance = formatTargetDistance(this.state.distanceToTargetMeters)
  540. const hasHeartRate = hasHeartRateSignal(this.state)
  541. const heartRateTone = hasHeartRate
  542. ? getHeartRateTone(this.state.heartRateBpm, this.config)
  543. : getSpeedFallbackTone(this.state.currentSpeedKmh)
  544. const heartRate = formatHeartRateMetric(this.state.heartRateBpm)
  545. const calories = formatCaloriesMetric(this.state.caloriesKcal)
  546. const accuracy = formatAccuracyMetric(this.state.lastGpsAccuracyMeters)
  547. return {
  548. ...EMPTY_TELEMETRY_PRESENTATION,
  549. timerText: formatElapsedTimerText(this.state.elapsedMs),
  550. mileageText: formatDistanceText(this.state.distanceMeters),
  551. distanceToTargetValueText: targetDistance.valueText,
  552. distanceToTargetUnitText: targetDistance.unitText,
  553. speedText: formatSpeedText(this.state.currentSpeedKmh),
  554. heartRateTone,
  555. heartRateZoneNameText: hasHeartRate || hasSpeedSignal(this.state) ? getHeartRateToneLabel(heartRateTone) : '--',
  556. heartRateZoneRangeText: hasHeartRate
  557. ? getHeartRateToneRangeText(heartRateTone)
  558. : hasSpeedSignal(this.state)
  559. ? getSpeedToneRangeText(heartRateTone)
  560. : '',
  561. heartRateValueText: heartRate.valueText,
  562. heartRateUnitText: heartRate.unitText,
  563. caloriesValueText: calories.valueText,
  564. caloriesUnitText: calories.unitText,
  565. averageSpeedValueText: formatSpeedText(this.state.averageSpeedKmh),
  566. averageSpeedUnitText: 'km/h',
  567. accuracyValueText: accuracy.valueText,
  568. accuracyUnitText: accuracy.unitText,
  569. }
  570. }
  571. private syncCalorieAccumulation(now: number): void {
  572. if (!shouldTrackCalories(this.state)) {
  573. return
  574. }
  575. if (this.state.calorieTrackingAt === null) {
  576. this.state = {
  577. ...this.state,
  578. calorieTrackingAt: now,
  579. caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal,
  580. }
  581. return
  582. }
  583. if (now <= this.state.calorieTrackingAt) {
  584. return
  585. }
  586. const deltaMs = now - this.state.calorieTrackingAt
  587. const calorieDelta = hasHeartRateSignal(this.state)
  588. ? estimateCaloriesKcal(deltaMs, this.state.heartRateBpm as number, this.config)
  589. : estimateCaloriesFromSpeedKcal(deltaMs, this.state.currentSpeedKmh, this.config)
  590. this.state = {
  591. ...this.state,
  592. calorieTrackingAt: now,
  593. caloriesKcal: (this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal) + calorieDelta,
  594. }
  595. }
  596. private alignCalorieTracking(now: number): void {
  597. if (shouldTrackCalories(this.state)) {
  598. if (this.state.calorieTrackingAt === null) {
  599. this.state = {
  600. ...this.state,
  601. calorieTrackingAt: now,
  602. caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal,
  603. }
  604. }
  605. return
  606. }
  607. if (this.state.calorieTrackingAt !== null) {
  608. this.state = {
  609. ...this.state,
  610. calorieTrackingAt: null,
  611. caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal,
  612. }
  613. }
  614. }
  615. }