720 lines
20 KiB
TypeScript
720 lines
20 KiB
TypeScript
import { type GameDefinition } from '../core/gameDefinition'
|
|
import {
|
|
DEFAULT_TELEMETRY_CONFIG,
|
|
getHeartRateToneLabel,
|
|
getHeartRateToneRangeText,
|
|
getSpeedToneRangeText,
|
|
mergeTelemetryConfig,
|
|
type HeartRateTone,
|
|
type TelemetryConfig,
|
|
} from './telemetryConfig'
|
|
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'
|
|
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 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
|
|
|
|
constructor() {
|
|
this.state = { ...EMPTY_TELEMETRY_STATE }
|
|
this.config = { ...DEFAULT_TELEMETRY_CONFIG }
|
|
}
|
|
|
|
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.config = mergeTelemetryConfig(config)
|
|
}
|
|
|
|
loadDefinition(_definition: GameDefinition): void {
|
|
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 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: formatElapsedTimerText(this.state.elapsedMs),
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
}
|