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 TelemetryState } from './telemetryState' const SPEED_SMOOTHING_ALPHA = 0.35 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 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 } } configure(config?: Partial | 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 === '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 this.state = { ...this.state, elapsedMs, distanceToTargetMeters, averageSpeedKmh, } } 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, } } } }