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