telemetryRuntime.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  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 { EMPTY_TELEMETRY_STATE, type TelemetryState } from './telemetryState'
  15. const SPEED_SMOOTHING_ALPHA = 0.35
  16. function getApproxDistanceMeters(
  17. a: { lon: number; lat: number },
  18. b: { lon: number; lat: number },
  19. ): number {
  20. const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
  21. const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
  22. const dy = (b.lat - a.lat) * 110540
  23. return Math.sqrt(dx * dx + dy * dy)
  24. }
  25. function formatElapsedTimerText(totalMs: number): string {
  26. const safeMs = Math.max(0, totalMs)
  27. const totalSeconds = Math.floor(safeMs / 1000)
  28. const hours = Math.floor(totalSeconds / 3600)
  29. const minutes = Math.floor((totalSeconds % 3600) / 60)
  30. const seconds = totalSeconds % 60
  31. return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
  32. }
  33. function formatDistanceText(distanceMeters: number): string {
  34. if (distanceMeters >= 1000) {
  35. return `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}km`
  36. }
  37. return `${Math.round(distanceMeters)}m`
  38. }
  39. function formatTargetDistance(distanceMeters: number | null): { valueText: string; unitText: string } {
  40. if (distanceMeters === null) {
  41. return {
  42. valueText: '--',
  43. unitText: '',
  44. }
  45. }
  46. return distanceMeters >= 1000
  47. ? {
  48. valueText: `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}`,
  49. unitText: 'km',
  50. }
  51. : {
  52. valueText: String(Math.round(distanceMeters)),
  53. unitText: 'm',
  54. }
  55. }
  56. function formatSpeedText(speedKmh: number | null): string {
  57. if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 0.05) {
  58. return '0'
  59. }
  60. return speedKmh >= 10 ? speedKmh.toFixed(1) : speedKmh.toFixed(2)
  61. }
  62. function smoothSpeedKmh(previousSpeedKmh: number | null, nextSpeedKmh: number): number {
  63. if (previousSpeedKmh === null || !Number.isFinite(previousSpeedKmh)) {
  64. return nextSpeedKmh
  65. }
  66. return previousSpeedKmh + (nextSpeedKmh - previousSpeedKmh) * SPEED_SMOOTHING_ALPHA
  67. }
  68. function getHeartRateTone(
  69. heartRateBpm: number | null,
  70. telemetryConfig: TelemetryConfig,
  71. ): HeartRateTone {
  72. if (heartRateBpm === null || !Number.isFinite(heartRateBpm) || heartRateBpm <= 0) {
  73. return 'blue'
  74. }
  75. const maxHeartRate = Math.max(120, 220 - telemetryConfig.heartRateAge)
  76. const restingHeartRate = Math.min(maxHeartRate - 15, telemetryConfig.restingHeartRateBpm)
  77. const reserve = Math.max(20, maxHeartRate - restingHeartRate)
  78. const blueLimit = restingHeartRate + reserve * 0.39
  79. const purpleLimit = restingHeartRate + reserve * 0.54
  80. const greenLimit = restingHeartRate + reserve * 0.69
  81. const yellowLimit = restingHeartRate + reserve * 0.79
  82. const orangeLimit = restingHeartRate + reserve * 0.89
  83. if (heartRateBpm <= blueLimit) {
  84. return 'blue'
  85. }
  86. if (heartRateBpm <= purpleLimit) {
  87. return 'purple'
  88. }
  89. if (heartRateBpm <= greenLimit) {
  90. return 'green'
  91. }
  92. if (heartRateBpm <= yellowLimit) {
  93. return 'yellow'
  94. }
  95. if (heartRateBpm <= orangeLimit) {
  96. return 'orange'
  97. }
  98. return 'red'
  99. }
  100. function getSpeedFallbackTone(speedKmh: number | null): HeartRateTone {
  101. if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 3.2) {
  102. return 'blue'
  103. }
  104. if (speedKmh <= 4.0) {
  105. return 'purple'
  106. }
  107. if (speedKmh <= 5.5) {
  108. return 'green'
  109. }
  110. if (speedKmh <= 7.1) {
  111. return 'yellow'
  112. }
  113. if (speedKmh <= 8.8) {
  114. return 'orange'
  115. }
  116. return 'red'
  117. }
  118. function formatHeartRateMetric(heartRateBpm: number | null): { valueText: string; unitText: string } {
  119. if (heartRateBpm === null || !Number.isFinite(heartRateBpm) || heartRateBpm <= 0) {
  120. return {
  121. valueText: '--',
  122. unitText: '',
  123. }
  124. }
  125. return {
  126. valueText: String(Math.round(heartRateBpm)),
  127. unitText: 'bpm',
  128. }
  129. }
  130. function formatCaloriesMetric(caloriesKcal: number | null): { valueText: string; unitText: string } {
  131. if (caloriesKcal === null || !Number.isFinite(caloriesKcal) || caloriesKcal < 0) {
  132. return {
  133. valueText: '0',
  134. unitText: 'kcal',
  135. }
  136. }
  137. return {
  138. valueText: String(Math.round(caloriesKcal)),
  139. unitText: 'kcal',
  140. }
  141. }
  142. function formatAccuracyMetric(accuracyMeters: number | null): { valueText: string; unitText: string } {
  143. if (accuracyMeters === null || !Number.isFinite(accuracyMeters) || accuracyMeters < 0) {
  144. return {
  145. valueText: '--',
  146. unitText: '',
  147. }
  148. }
  149. return {
  150. valueText: String(Math.round(accuracyMeters)),
  151. unitText: 'm',
  152. }
  153. }
  154. function estimateCaloriesKcal(
  155. elapsedMs: number,
  156. heartRateBpm: number,
  157. telemetryConfig: TelemetryConfig,
  158. ): number {
  159. if (elapsedMs <= 0) {
  160. return 0
  161. }
  162. if (!Number.isFinite(heartRateBpm) || heartRateBpm <= 0) {
  163. return 0
  164. }
  165. const maxHeartRate = Math.max(120, 220 - telemetryConfig.heartRateAge)
  166. const restingHeartRate = Math.min(maxHeartRate - 15, telemetryConfig.restingHeartRateBpm)
  167. const reserve = Math.max(20, maxHeartRate - restingHeartRate)
  168. const intensity = Math.max(0, Math.min(1, (heartRateBpm - restingHeartRate) / reserve))
  169. const met = 2 + intensity * 10
  170. return met * telemetryConfig.userWeightKg * (elapsedMs / 3600000)
  171. }
  172. function estimateCaloriesFromSpeedKcal(
  173. elapsedMs: number,
  174. speedKmh: number | null,
  175. telemetryConfig: TelemetryConfig,
  176. ): number {
  177. if (elapsedMs <= 0 || speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 0.5) {
  178. return 0
  179. }
  180. let met = 2
  181. if (speedKmh >= 8.9) {
  182. met = 9.8
  183. } else if (speedKmh >= 7.2) {
  184. met = 7.8
  185. } else if (speedKmh >= 5.6) {
  186. met = 6
  187. } else if (speedKmh >= 4.1) {
  188. met = 4.3
  189. } else if (speedKmh >= 3.2) {
  190. met = 3.0
  191. }
  192. return (met * 3.5 * telemetryConfig.userWeightKg / 200) * (elapsedMs / 60000)
  193. }
  194. function hasHeartRateSignal(state: TelemetryState): boolean {
  195. return state.heartRateBpm !== null
  196. && Number.isFinite(state.heartRateBpm)
  197. && state.heartRateBpm > 0
  198. }
  199. function hasSpeedSignal(state: TelemetryState): boolean {
  200. return state.currentSpeedKmh !== null
  201. && Number.isFinite(state.currentSpeedKmh)
  202. && state.currentSpeedKmh >= 0.5
  203. }
  204. function shouldTrackCalories(state: TelemetryState): boolean {
  205. return state.sessionStatus === 'running'
  206. && state.sessionEndedAt === null
  207. && (hasHeartRateSignal(state) || hasSpeedSignal(state))
  208. }
  209. export class TelemetryRuntime {
  210. state: TelemetryState
  211. config: TelemetryConfig
  212. constructor() {
  213. this.state = { ...EMPTY_TELEMETRY_STATE }
  214. this.config = { ...DEFAULT_TELEMETRY_CONFIG }
  215. }
  216. reset(): void {
  217. this.state = { ...EMPTY_TELEMETRY_STATE }
  218. }
  219. configure(config?: Partial<TelemetryConfig> | null): void {
  220. this.config = mergeTelemetryConfig(config)
  221. }
  222. loadDefinition(_definition: GameDefinition): void {
  223. this.reset()
  224. }
  225. syncGameState(definition: GameDefinition | null, state: GameSessionState | null, hudTargetControlId?: string | null): void {
  226. if (!definition || !state) {
  227. this.dispatch({ type: 'reset' })
  228. return
  229. }
  230. const targetControlId = hudTargetControlId !== undefined ? hudTargetControlId : state.currentTargetControlId
  231. const targetControl = targetControlId
  232. ? definition.controls.find((control) => control.id === targetControlId) || null
  233. : null
  234. this.dispatch({
  235. type: 'session_state_updated',
  236. at: Date.now(),
  237. status: state.status,
  238. startedAt: state.startedAt,
  239. endedAt: state.endedAt,
  240. })
  241. this.dispatch({
  242. type: 'target_updated',
  243. controlId: targetControl ? targetControl.id : null,
  244. point: targetControl ? targetControl.point : null,
  245. })
  246. }
  247. dispatch(event: TelemetryEvent): void {
  248. if (event.type === 'reset') {
  249. this.reset()
  250. return
  251. }
  252. if (event.type === 'session_state_updated') {
  253. this.syncCalorieAccumulation(event.at)
  254. this.state = {
  255. ...this.state,
  256. sessionStatus: event.status,
  257. sessionStartedAt: event.startedAt,
  258. sessionEndedAt: event.endedAt,
  259. elapsedMs: event.startedAt === null ? 0 : Math.max(0, (event.endedAt || Date.now()) - event.startedAt),
  260. }
  261. this.alignCalorieTracking(event.at)
  262. this.recomputeDerivedState()
  263. return
  264. }
  265. if (event.type === 'target_updated') {
  266. this.state = {
  267. ...this.state,
  268. targetControlId: event.controlId,
  269. targetPoint: event.point,
  270. }
  271. this.recomputeDerivedState()
  272. return
  273. }
  274. if (event.type === 'gps_updated') {
  275. this.syncCalorieAccumulation(event.at)
  276. const nextPoint = { lon: event.lon, lat: event.lat }
  277. const previousPoint = this.state.lastGpsPoint
  278. const previousAt = this.state.lastGpsAt
  279. let nextDistanceMeters = this.state.distanceMeters
  280. let nextSpeedKmh = this.state.currentSpeedKmh
  281. if (previousPoint && previousAt !== null && event.at > previousAt) {
  282. const segmentMeters = getApproxDistanceMeters(previousPoint, nextPoint)
  283. nextDistanceMeters += segmentMeters
  284. const rawSpeedKmh = segmentMeters <= 0
  285. ? 0
  286. : (segmentMeters / ((event.at - previousAt) / 1000)) * 3.6
  287. nextSpeedKmh = smoothSpeedKmh(this.state.currentSpeedKmh, rawSpeedKmh)
  288. }
  289. this.state = {
  290. ...this.state,
  291. distanceMeters: nextDistanceMeters,
  292. currentSpeedKmh: nextSpeedKmh,
  293. lastGpsPoint: nextPoint,
  294. lastGpsAt: event.at,
  295. lastGpsAccuracyMeters: event.accuracyMeters,
  296. }
  297. this.alignCalorieTracking(event.at)
  298. this.recomputeDerivedState()
  299. return
  300. }
  301. if (event.type === 'heart_rate_updated') {
  302. this.syncCalorieAccumulation(event.at)
  303. this.state = {
  304. ...this.state,
  305. heartRateBpm: event.bpm,
  306. }
  307. this.alignCalorieTracking(event.at)
  308. this.recomputeDerivedState()
  309. }
  310. }
  311. recomputeDerivedState(now = Date.now()): void {
  312. const elapsedMs = this.state.sessionStartedAt === null
  313. ? 0
  314. : Math.max(0, (this.state.sessionEndedAt || now) - this.state.sessionStartedAt)
  315. const distanceToTargetMeters = this.state.lastGpsPoint && this.state.targetPoint
  316. ? getApproxDistanceMeters(this.state.lastGpsPoint, this.state.targetPoint)
  317. : null
  318. const averageSpeedKmh = elapsedMs > 0
  319. ? (this.state.distanceMeters / (elapsedMs / 1000)) * 3.6
  320. : null
  321. this.state = {
  322. ...this.state,
  323. elapsedMs,
  324. distanceToTargetMeters,
  325. averageSpeedKmh,
  326. }
  327. }
  328. getPresentation(now = Date.now()): TelemetryPresentation {
  329. this.syncCalorieAccumulation(now)
  330. this.alignCalorieTracking(now)
  331. this.recomputeDerivedState(now)
  332. const targetDistance = formatTargetDistance(this.state.distanceToTargetMeters)
  333. const hasHeartRate = hasHeartRateSignal(this.state)
  334. const heartRateTone = hasHeartRate
  335. ? getHeartRateTone(this.state.heartRateBpm, this.config)
  336. : getSpeedFallbackTone(this.state.currentSpeedKmh)
  337. const heartRate = formatHeartRateMetric(this.state.heartRateBpm)
  338. const calories = formatCaloriesMetric(this.state.caloriesKcal)
  339. const accuracy = formatAccuracyMetric(this.state.lastGpsAccuracyMeters)
  340. return {
  341. ...EMPTY_TELEMETRY_PRESENTATION,
  342. timerText: formatElapsedTimerText(this.state.elapsedMs),
  343. mileageText: formatDistanceText(this.state.distanceMeters),
  344. distanceToTargetValueText: targetDistance.valueText,
  345. distanceToTargetUnitText: targetDistance.unitText,
  346. speedText: formatSpeedText(this.state.currentSpeedKmh),
  347. heartRateTone,
  348. heartRateZoneNameText: hasHeartRate || hasSpeedSignal(this.state) ? getHeartRateToneLabel(heartRateTone) : '--',
  349. heartRateZoneRangeText: hasHeartRate
  350. ? getHeartRateToneRangeText(heartRateTone)
  351. : hasSpeedSignal(this.state)
  352. ? getSpeedToneRangeText(heartRateTone)
  353. : '',
  354. heartRateValueText: heartRate.valueText,
  355. heartRateUnitText: heartRate.unitText,
  356. caloriesValueText: calories.valueText,
  357. caloriesUnitText: calories.unitText,
  358. averageSpeedValueText: formatSpeedText(this.state.averageSpeedKmh),
  359. averageSpeedUnitText: 'km/h',
  360. accuracyValueText: accuracy.valueText,
  361. accuracyUnitText: accuracy.unitText,
  362. }
  363. }
  364. private syncCalorieAccumulation(now: number): void {
  365. if (!shouldTrackCalories(this.state)) {
  366. return
  367. }
  368. if (this.state.calorieTrackingAt === null) {
  369. this.state = {
  370. ...this.state,
  371. calorieTrackingAt: now,
  372. caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal,
  373. }
  374. return
  375. }
  376. if (now <= this.state.calorieTrackingAt) {
  377. return
  378. }
  379. const deltaMs = now - this.state.calorieTrackingAt
  380. const calorieDelta = hasHeartRateSignal(this.state)
  381. ? estimateCaloriesKcal(deltaMs, this.state.heartRateBpm as number, this.config)
  382. : estimateCaloriesFromSpeedKcal(deltaMs, this.state.currentSpeedKmh, this.config)
  383. this.state = {
  384. ...this.state,
  385. calorieTrackingAt: now,
  386. caloriesKcal: (this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal) + calorieDelta,
  387. }
  388. }
  389. private alignCalorieTracking(now: number): void {
  390. if (shouldTrackCalories(this.state)) {
  391. if (this.state.calorieTrackingAt === null) {
  392. this.state = {
  393. ...this.state,
  394. calorieTrackingAt: now,
  395. caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal,
  396. }
  397. }
  398. return
  399. }
  400. if (this.state.calorieTrackingAt !== null) {
  401. this.state = {
  402. ...this.state,
  403. calorieTrackingAt: null,
  404. caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal,
  405. }
  406. }
  407. }
  408. }