telemetryRuntime.ts 25 KB

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