telemetryRuntime.ts 19 KB

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