Add mock heart rate simulator flow
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
|
import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
|
||||||
import { CompassHeadingController } from '../sensor/compassHeadingController'
|
import { CompassHeadingController } from '../sensor/compassHeadingController'
|
||||||
import { HeartRateController, type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
|
import { type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
|
||||||
|
import { HeartRateInputController } from '../sensor/heartRateInputController'
|
||||||
import { LocationController } from '../sensor/locationController'
|
import { LocationController } from '../sensor/locationController'
|
||||||
import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
|
import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
|
||||||
import { type MapRendererStats } from '../renderer/mapRenderer'
|
import { type MapRendererStats } from '../renderer/mapRenderer'
|
||||||
@@ -129,6 +130,8 @@ export interface MapEngineViewState {
|
|||||||
mockCoordText: string
|
mockCoordText: string
|
||||||
mockSpeedText: string
|
mockSpeedText: string
|
||||||
gpsCoordText: string
|
gpsCoordText: string
|
||||||
|
heartRateSourceMode: 'real' | 'mock'
|
||||||
|
heartRateSourceText: string
|
||||||
heartRateConnected: boolean
|
heartRateConnected: boolean
|
||||||
heartRateStatusText: string
|
heartRateStatusText: string
|
||||||
heartRateDeviceText: string
|
heartRateDeviceText: string
|
||||||
@@ -140,6 +143,10 @@ export interface MapEngineViewState {
|
|||||||
preferred: boolean
|
preferred: boolean
|
||||||
connected: boolean
|
connected: boolean
|
||||||
}>
|
}>
|
||||||
|
mockHeartRateBridgeConnected: boolean
|
||||||
|
mockHeartRateBridgeStatusText: string
|
||||||
|
mockHeartRateBridgeUrlText: string
|
||||||
|
mockHeartRateText: string
|
||||||
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
|
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
|
||||||
gameModeText: string
|
gameModeText: string
|
||||||
panelTimerText: string
|
panelTimerText: string
|
||||||
@@ -232,11 +239,17 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
|||||||
'mockCoordText',
|
'mockCoordText',
|
||||||
'mockSpeedText',
|
'mockSpeedText',
|
||||||
'gpsCoordText',
|
'gpsCoordText',
|
||||||
|
'heartRateSourceMode',
|
||||||
|
'heartRateSourceText',
|
||||||
'heartRateConnected',
|
'heartRateConnected',
|
||||||
'heartRateStatusText',
|
'heartRateStatusText',
|
||||||
'heartRateDeviceText',
|
'heartRateDeviceText',
|
||||||
'heartRateScanText',
|
'heartRateScanText',
|
||||||
'heartRateDiscoveredDevices',
|
'heartRateDiscoveredDevices',
|
||||||
|
'mockHeartRateBridgeConnected',
|
||||||
|
'mockHeartRateBridgeStatusText',
|
||||||
|
'mockHeartRateBridgeUrlText',
|
||||||
|
'mockHeartRateText',
|
||||||
'gameSessionStatus',
|
'gameSessionStatus',
|
||||||
'gameModeText',
|
'gameModeText',
|
||||||
'panelTimerText',
|
'panelTimerText',
|
||||||
@@ -514,7 +527,7 @@ export class MapEngine {
|
|||||||
renderer: WebGLMapRenderer
|
renderer: WebGLMapRenderer
|
||||||
compassController: CompassHeadingController
|
compassController: CompassHeadingController
|
||||||
locationController: LocationController
|
locationController: LocationController
|
||||||
heartRateController: HeartRateController
|
heartRateController: HeartRateInputController
|
||||||
feedbackDirector: FeedbackDirector
|
feedbackDirector: FeedbackDirector
|
||||||
onData: (patch: Partial<MapEngineViewState>) => void
|
onData: (patch: Partial<MapEngineViewState>) => void
|
||||||
state: MapEngineViewState
|
state: MapEngineViewState
|
||||||
@@ -622,7 +635,7 @@ export class MapEngine {
|
|||||||
this.setState(this.getLocationControllerViewPatch(), true)
|
this.setState(this.getLocationControllerViewPatch(), true)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
this.heartRateController = new HeartRateController({
|
this.heartRateController = new HeartRateInputController({
|
||||||
onHeartRate: (bpm) => {
|
onHeartRate: (bpm) => {
|
||||||
this.telemetryRuntime.dispatch({
|
this.telemetryRuntime.dispatch({
|
||||||
type: 'heart_rate_updated',
|
type: 'heart_rate_updated',
|
||||||
@@ -639,6 +652,7 @@ export class MapEngine {
|
|||||||
heartRateStatusText: message,
|
heartRateStatusText: message,
|
||||||
heartRateDeviceText: deviceName,
|
heartRateDeviceText: deviceName,
|
||||||
heartRateScanText: this.getHeartRateScanText(),
|
heartRateScanText: this.getHeartRateScanText(),
|
||||||
|
...this.getHeartRateControllerViewPatch(),
|
||||||
}, true)
|
}, true)
|
||||||
},
|
},
|
||||||
onError: (message) => {
|
onError: (message) => {
|
||||||
@@ -651,6 +665,7 @@ export class MapEngine {
|
|||||||
heartRateStatusText: message,
|
heartRateStatusText: message,
|
||||||
heartRateDeviceText: deviceName,
|
heartRateDeviceText: deviceName,
|
||||||
heartRateScanText: this.getHeartRateScanText(),
|
heartRateScanText: this.getHeartRateScanText(),
|
||||||
|
...this.getHeartRateControllerViewPatch(),
|
||||||
statusText: `${message} (${this.buildVersion})`,
|
statusText: `${message} (${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
},
|
},
|
||||||
@@ -667,18 +682,23 @@ export class MapEngine {
|
|||||||
heartRateConnected: connected,
|
heartRateConnected: connected,
|
||||||
heartRateDeviceText: resolvedDeviceName,
|
heartRateDeviceText: resolvedDeviceName,
|
||||||
heartRateStatusText: connected
|
heartRateStatusText: connected
|
||||||
? '心率带已连接'
|
? (this.heartRateController.sourceMode === 'mock' ? '模拟心率源已连接' : '心率带已连接')
|
||||||
: (this.heartRateController.reconnecting ? '心率带自动重连中' : '心率带未连接'),
|
: (this.heartRateController.reconnecting ? '心率带自动重连中' : (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接')),
|
||||||
heartRateScanText: this.getHeartRateScanText(),
|
heartRateScanText: this.getHeartRateScanText(),
|
||||||
heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
|
heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
|
||||||
|
...this.getHeartRateControllerViewPatch(),
|
||||||
}, true)
|
}, true)
|
||||||
},
|
},
|
||||||
onDeviceListChange: (devices) => {
|
onDeviceListChange: (devices) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
|
heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
|
||||||
heartRateScanText: this.getHeartRateScanText(),
|
heartRateScanText: this.getHeartRateScanText(),
|
||||||
|
...this.getHeartRateControllerViewPatch(),
|
||||||
}, true)
|
}, true)
|
||||||
},
|
},
|
||||||
|
onDebugStateChange: () => {
|
||||||
|
this.setState(this.getHeartRateControllerViewPatch(), true)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
this.feedbackDirector = new FeedbackDirector({
|
this.feedbackDirector = new FeedbackDirector({
|
||||||
showPunchFeedback: (text, tone, motionClass) => {
|
showPunchFeedback: (text, tone, motionClass) => {
|
||||||
@@ -782,11 +802,17 @@ export class MapEngine {
|
|||||||
mockCoordText: '--',
|
mockCoordText: '--',
|
||||||
mockSpeedText: '--',
|
mockSpeedText: '--',
|
||||||
gpsCoordText: '--',
|
gpsCoordText: '--',
|
||||||
|
heartRateSourceMode: 'real',
|
||||||
|
heartRateSourceText: '真实心率',
|
||||||
heartRateConnected: false,
|
heartRateConnected: false,
|
||||||
heartRateStatusText: '心率带未连接',
|
heartRateStatusText: '心率带未连接',
|
||||||
heartRateDeviceText: '--',
|
heartRateDeviceText: '--',
|
||||||
heartRateScanText: '未扫描',
|
heartRateScanText: '未扫描',
|
||||||
heartRateDiscoveredDevices: [],
|
heartRateDiscoveredDevices: [],
|
||||||
|
mockHeartRateBridgeConnected: false,
|
||||||
|
mockHeartRateBridgeStatusText: '未连接',
|
||||||
|
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
|
||||||
|
mockHeartRateText: '--',
|
||||||
panelTimerText: '00:00:00',
|
panelTimerText: '00:00:00',
|
||||||
panelMileageText: '0m',
|
panelMileageText: '0m',
|
||||||
panelActionTagText: '目标',
|
panelActionTagText: '目标',
|
||||||
@@ -951,6 +977,18 @@ export class MapEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getHeartRateControllerViewPatch(): Partial<MapEngineViewState> {
|
||||||
|
const debugState = this.heartRateController.getDebugState()
|
||||||
|
return {
|
||||||
|
heartRateSourceMode: debugState.sourceMode,
|
||||||
|
heartRateSourceText: debugState.sourceModeText,
|
||||||
|
mockHeartRateBridgeConnected: debugState.mockBridgeConnected,
|
||||||
|
mockHeartRateBridgeStatusText: debugState.mockBridgeStatusText,
|
||||||
|
mockHeartRateBridgeUrlText: debugState.mockBridgeUrlText,
|
||||||
|
mockHeartRateText: debugState.mockHeartRateText,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getGameModeText(): string {
|
getGameModeText(): string {
|
||||||
return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
|
return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
|
||||||
}
|
}
|
||||||
@@ -1442,6 +1480,26 @@ export class MapEngine {
|
|||||||
this.heartRateController.disconnect()
|
this.heartRateController.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleSetRealHeartRateMode(): void {
|
||||||
|
this.heartRateController.setSourceMode('real')
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSetMockHeartRateMode(): void {
|
||||||
|
this.heartRateController.setSourceMode('mock')
|
||||||
|
}
|
||||||
|
|
||||||
|
handleConnectMockHeartRateBridge(): void {
|
||||||
|
this.heartRateController.connectMockBridge()
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnectMockHeartRateBridge(): void {
|
||||||
|
this.heartRateController.disconnectMockBridge()
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSetMockHeartRateBridgeUrl(url: string): void {
|
||||||
|
this.heartRateController.setMockBridgeUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
handleConnectHeartRateDevice(deviceId: string): void {
|
handleConnectHeartRateDevice(deviceId: string): void {
|
||||||
this.heartRateController.connectToDiscoveredDevice(deviceId)
|
this.heartRateController.connectToDiscoveredDevice(deviceId)
|
||||||
}
|
}
|
||||||
@@ -1474,8 +1532,11 @@ export class MapEngine {
|
|||||||
bpm: null,
|
bpm: null,
|
||||||
})
|
})
|
||||||
this.setState({
|
this.setState({
|
||||||
heartRateStatusText: this.heartRateController.connected ? '心率带已连接' : '心率带未连接',
|
heartRateStatusText: this.heartRateController.connected
|
||||||
|
? (this.heartRateController.sourceMode === 'mock' ? '模拟心率源已连接' : '心率带已连接')
|
||||||
|
: (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接'),
|
||||||
heartRateScanText: this.getHeartRateScanText(),
|
heartRateScanText: this.getHeartRateScanText(),
|
||||||
|
...this.getHeartRateControllerViewPatch(),
|
||||||
}, true)
|
}, true)
|
||||||
this.syncSessionTimerText()
|
this.syncSessionTimerText()
|
||||||
}
|
}
|
||||||
@@ -1491,6 +1552,18 @@ export class MapEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getHeartRateScanText(): string {
|
getHeartRateScanText(): string {
|
||||||
|
if (this.heartRateController.sourceMode === 'mock') {
|
||||||
|
if (this.heartRateController.connected) {
|
||||||
|
return '模拟源已连接'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.heartRateController.connecting) {
|
||||||
|
return '模拟源连接中'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '模拟模式'
|
||||||
|
}
|
||||||
|
|
||||||
if (this.heartRateController.connected) {
|
if (this.heartRateController.connected) {
|
||||||
return '已连接'
|
return '已连接'
|
||||||
}
|
}
|
||||||
|
|||||||
325
miniprogram/engine/sensor/heartRateInputController.ts
Normal file
325
miniprogram/engine/sensor/heartRateInputController.ts
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import { HeartRateController, type HeartRateControllerCallbacks, type HeartRateDiscoveredDevice } from './heartRateController'
|
||||||
|
import { DEFAULT_MOCK_HEART_RATE_BRIDGE_URL, MockHeartRateBridge } from './mockHeartRateBridge'
|
||||||
|
|
||||||
|
export type HeartRateSourceMode = 'real' | 'mock'
|
||||||
|
|
||||||
|
export interface HeartRateInputControllerCallbacks {
|
||||||
|
onHeartRate: (bpm: number) => void
|
||||||
|
onStatus: (message: string) => void
|
||||||
|
onError: (message: string) => void
|
||||||
|
onConnectionChange: (connected: boolean, deviceName: string | null) => void
|
||||||
|
onDeviceListChange: (devices: HeartRateDiscoveredDevice[]) => void
|
||||||
|
onDebugStateChange?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeartRateInputControllerDebugState {
|
||||||
|
sourceMode: HeartRateSourceMode
|
||||||
|
sourceModeText: string
|
||||||
|
mockBridgeConnected: boolean
|
||||||
|
mockBridgeStatusText: string
|
||||||
|
mockBridgeUrlText: string
|
||||||
|
mockHeartRateText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSourceModeText(mode: HeartRateSourceMode): string {
|
||||||
|
return mode === 'mock' ? '模拟心率' : '真实心率'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMockHeartRateText(bpm: number | null): string {
|
||||||
|
return bpm === null ? '--' : `${bpm} bpm`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMockBridgeUrl(rawUrl: string): string {
|
||||||
|
const trimmed = rawUrl.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return DEFAULT_MOCK_HEART_RATE_BRIDGE_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = trimmed
|
||||||
|
if (!/^wss?:\/\//i.test(normalized)) {
|
||||||
|
normalized = `ws://${normalized.replace(/^\/+/, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/\/mock-gps(?:\?.*)?$/i.test(normalized)) {
|
||||||
|
normalized = normalized.replace(/\/+$/, '')
|
||||||
|
normalized = `${normalized}/mock-gps`
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HeartRateInputController {
|
||||||
|
callbacks: HeartRateInputControllerCallbacks
|
||||||
|
realController: HeartRateController
|
||||||
|
mockBridge: MockHeartRateBridge
|
||||||
|
sourceMode: HeartRateSourceMode
|
||||||
|
mockBridgeStatusText: string
|
||||||
|
mockBridgeUrl: string
|
||||||
|
mockBpm: number | null
|
||||||
|
|
||||||
|
constructor(callbacks: HeartRateInputControllerCallbacks) {
|
||||||
|
this.callbacks = callbacks
|
||||||
|
this.sourceMode = 'real'
|
||||||
|
this.mockBridgeUrl = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL
|
||||||
|
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
|
||||||
|
this.mockBpm = null
|
||||||
|
|
||||||
|
const realCallbacks: HeartRateControllerCallbacks = {
|
||||||
|
onHeartRate: (bpm) => {
|
||||||
|
if (this.sourceMode !== 'real') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callbacks.onHeartRate(bpm)
|
||||||
|
this.emitDebugState()
|
||||||
|
},
|
||||||
|
onStatus: (message) => {
|
||||||
|
if (this.sourceMode !== 'real') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callbacks.onStatus(message)
|
||||||
|
this.emitDebugState()
|
||||||
|
},
|
||||||
|
onError: (message) => {
|
||||||
|
if (this.sourceMode !== 'real') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callbacks.onError(message)
|
||||||
|
this.emitDebugState()
|
||||||
|
},
|
||||||
|
onConnectionChange: (connected, deviceName) => {
|
||||||
|
if (this.sourceMode !== 'real') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callbacks.onConnectionChange(connected, deviceName)
|
||||||
|
this.emitDebugState()
|
||||||
|
},
|
||||||
|
onDeviceListChange: (devices) => {
|
||||||
|
this.callbacks.onDeviceListChange(devices)
|
||||||
|
this.emitDebugState()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
this.realController = new HeartRateController(realCallbacks)
|
||||||
|
this.mockBridge = new MockHeartRateBridge({
|
||||||
|
onOpen: () => {
|
||||||
|
this.mockBridgeStatusText = `已连接 (${this.mockBridge.url})`
|
||||||
|
if (this.sourceMode === 'mock') {
|
||||||
|
this.callbacks.onConnectionChange(true, '模拟心率源')
|
||||||
|
this.callbacks.onStatus('模拟心率源已连接,等待外部输入')
|
||||||
|
}
|
||||||
|
this.emitDebugState()
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
this.mockBridgeStatusText = `未连接 (${this.mockBridge.url})`
|
||||||
|
if (this.sourceMode === 'mock') {
|
||||||
|
this.callbacks.onConnectionChange(false, null)
|
||||||
|
this.callbacks.onStatus('模拟心率源已断开')
|
||||||
|
}
|
||||||
|
this.emitDebugState()
|
||||||
|
},
|
||||||
|
onError: (message) => {
|
||||||
|
this.mockBridgeStatusText = `连接失败 (${this.mockBridge.url})`
|
||||||
|
if (this.sourceMode === 'mock') {
|
||||||
|
this.callbacks.onConnectionChange(false, null)
|
||||||
|
this.callbacks.onError(`模拟心率源错误: ${message}`)
|
||||||
|
}
|
||||||
|
this.emitDebugState()
|
||||||
|
},
|
||||||
|
onBpm: (bpm) => {
|
||||||
|
this.mockBpm = bpm
|
||||||
|
if (this.sourceMode === 'mock') {
|
||||||
|
this.callbacks.onHeartRate(bpm)
|
||||||
|
}
|
||||||
|
this.emitDebugState()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentDeviceId(): string | null {
|
||||||
|
if (this.sourceMode === 'mock') {
|
||||||
|
return this.mockBridge.connected ? 'mock-heart-rate' : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.realController.currentDeviceId
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentDeviceName(): string | null {
|
||||||
|
if (this.sourceMode === 'mock') {
|
||||||
|
return this.mockBridge.connected ? '模拟心率源' : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.realController.currentDeviceName
|
||||||
|
}
|
||||||
|
|
||||||
|
get connected(): boolean {
|
||||||
|
return this.sourceMode === 'mock' ? this.mockBridge.connected : this.realController.connected
|
||||||
|
}
|
||||||
|
|
||||||
|
get connecting(): boolean {
|
||||||
|
return this.sourceMode === 'mock' ? this.mockBridge.connecting : this.realController.connecting
|
||||||
|
}
|
||||||
|
|
||||||
|
get scanning(): boolean {
|
||||||
|
return this.sourceMode === 'mock' ? false : this.realController.scanning
|
||||||
|
}
|
||||||
|
|
||||||
|
get reconnecting(): boolean {
|
||||||
|
return this.sourceMode === 'mock' ? false : this.realController.reconnecting
|
||||||
|
}
|
||||||
|
|
||||||
|
get disconnecting(): boolean {
|
||||||
|
return this.sourceMode === 'mock' ? false : this.realController.disconnecting
|
||||||
|
}
|
||||||
|
|
||||||
|
get discoveredDevices(): HeartRateDiscoveredDevice[] {
|
||||||
|
return this.realController.discoveredDevices
|
||||||
|
}
|
||||||
|
|
||||||
|
get lastDeviceId(): string | null {
|
||||||
|
return this.realController.lastDeviceId
|
||||||
|
}
|
||||||
|
|
||||||
|
get lastDeviceName(): string | null {
|
||||||
|
return this.realController.lastDeviceName
|
||||||
|
}
|
||||||
|
|
||||||
|
getDebugState(): HeartRateInputControllerDebugState {
|
||||||
|
return {
|
||||||
|
sourceMode: this.sourceMode,
|
||||||
|
sourceModeText: formatSourceModeText(this.sourceMode),
|
||||||
|
mockBridgeConnected: this.mockBridge.connected,
|
||||||
|
mockBridgeStatusText: this.mockBridgeStatusText,
|
||||||
|
mockBridgeUrlText: this.mockBridgeUrl,
|
||||||
|
mockHeartRateText: formatMockHeartRateText(this.mockBpm),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startScanAndConnect(): void {
|
||||||
|
if (this.sourceMode === 'mock') {
|
||||||
|
this.callbacks.onStatus(this.mockBridge.connected ? '模拟心率源已连接' : '当前为模拟心率模式,请连接模拟源')
|
||||||
|
this.emitDebugState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.realController.startScanAndConnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
if (this.sourceMode === 'mock') {
|
||||||
|
if (!this.mockBridge.connected && !this.mockBridge.connecting) {
|
||||||
|
this.callbacks.onStatus('模拟心率源未连接')
|
||||||
|
this.emitDebugState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mockBridge.disconnect()
|
||||||
|
this.emitDebugState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.realController.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.realController.destroy()
|
||||||
|
this.mockBridge.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
setSourceMode(mode: HeartRateSourceMode): void {
|
||||||
|
if (this.sourceMode === mode) {
|
||||||
|
this.callbacks.onStatus(`${formatSourceModeText(mode)}已启用`)
|
||||||
|
this.emitDebugState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousMode = this.sourceMode
|
||||||
|
this.sourceMode = mode
|
||||||
|
|
||||||
|
if (previousMode === 'real') {
|
||||||
|
this.realController.disconnect()
|
||||||
|
} else {
|
||||||
|
this.callbacks.onConnectionChange(false, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeDeviceName = this.currentDeviceName
|
||||||
|
this.callbacks.onConnectionChange(this.connected, activeDeviceName)
|
||||||
|
this.callbacks.onStatus(mode === 'mock' ? '已切换到模拟心率模式' : '已切换到真实心率模式')
|
||||||
|
this.emitDebugState()
|
||||||
|
}
|
||||||
|
|
||||||
|
setMockBridgeUrl(url: string): void {
|
||||||
|
this.mockBridgeUrl = normalizeMockBridgeUrl(url)
|
||||||
|
|
||||||
|
if (this.mockBridge.connected || this.mockBridge.connecting) {
|
||||||
|
this.mockBridgeStatusText = `已设置新地址,重连生效 (${this.mockBridgeUrl})`
|
||||||
|
if (this.sourceMode === 'mock') {
|
||||||
|
this.callbacks.onStatus('模拟心率源地址已更新,重连后生效')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
|
||||||
|
if (this.sourceMode === 'mock') {
|
||||||
|
this.callbacks.onStatus('模拟心率源地址已更新')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitDebugState()
|
||||||
|
}
|
||||||
|
|
||||||
|
connectMockBridge(url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL): void {
|
||||||
|
if (this.mockBridge.connected || this.mockBridge.connecting) {
|
||||||
|
if (this.sourceMode === 'mock') {
|
||||||
|
this.callbacks.onStatus('模拟心率源已连接')
|
||||||
|
}
|
||||||
|
this.emitDebugState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = normalizeMockBridgeUrl(url === DEFAULT_MOCK_HEART_RATE_BRIDGE_URL ? this.mockBridgeUrl : url)
|
||||||
|
this.mockBridgeUrl = targetUrl
|
||||||
|
this.mockBridgeStatusText = `连接中 (${targetUrl})`
|
||||||
|
if (this.sourceMode === 'mock') {
|
||||||
|
this.callbacks.onStatus('模拟心率源连接中')
|
||||||
|
}
|
||||||
|
this.emitDebugState()
|
||||||
|
this.mockBridge.connect(targetUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectMockBridge(): void {
|
||||||
|
if (!this.mockBridge.connected && !this.mockBridge.connecting) {
|
||||||
|
if (this.sourceMode === 'mock') {
|
||||||
|
this.callbacks.onStatus('模拟心率源未连接')
|
||||||
|
}
|
||||||
|
this.emitDebugState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mockBridge.disconnect()
|
||||||
|
this.mockBridgeStatusText = `未连接 (${this.mockBridge.url})`
|
||||||
|
this.emitDebugState()
|
||||||
|
}
|
||||||
|
|
||||||
|
connectToDiscoveredDevice(deviceId: string): void {
|
||||||
|
if (this.sourceMode !== 'real') {
|
||||||
|
this.callbacks.onStatus('当前为模拟心率模式,无法连接真实心率带')
|
||||||
|
this.emitDebugState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.realController.connectToDiscoveredDevice(deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPreferredDevice(): void {
|
||||||
|
this.realController.clearPreferredDevice()
|
||||||
|
this.emitDebugState()
|
||||||
|
}
|
||||||
|
|
||||||
|
emitDebugState(): void {
|
||||||
|
if (this.callbacks.onDebugStateChange) {
|
||||||
|
this.callbacks.onDebugStateChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
miniprogram/engine/sensor/mockHeartRateBridge.ts
Normal file
134
miniprogram/engine/sensor/mockHeartRateBridge.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
export const DEFAULT_MOCK_HEART_RATE_BRIDGE_URL = 'wss://gs.gotomars.xyz/mock-gps'
|
||||||
|
|
||||||
|
export interface MockHeartRateBridgeCallbacks {
|
||||||
|
onOpen: () => void
|
||||||
|
onClose: (message: string) => void
|
||||||
|
onError: (message: string) => void
|
||||||
|
onBpm: (bpm: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawMockHeartRateMessage = {
|
||||||
|
type?: string
|
||||||
|
timestamp?: number
|
||||||
|
bpm?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseMessage(data: string): RawMockHeartRateMessage | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data) as RawMockHeartRateMessage
|
||||||
|
} catch (_error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHeartRateValue(message: RawMockHeartRateMessage): number | null {
|
||||||
|
if (message.type !== 'mock_heart_rate' || !Number.isFinite(message.bpm)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const bpm = Math.round(Number(message.bpm))
|
||||||
|
if (bpm <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return bpm
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockHeartRateBridge {
|
||||||
|
callbacks: MockHeartRateBridgeCallbacks
|
||||||
|
socketTask: WechatMiniprogram.SocketTask | null
|
||||||
|
connected: boolean
|
||||||
|
connecting: boolean
|
||||||
|
url: string
|
||||||
|
|
||||||
|
constructor(callbacks: MockHeartRateBridgeCallbacks) {
|
||||||
|
this.callbacks = callbacks
|
||||||
|
this.socketTask = null
|
||||||
|
this.connected = false
|
||||||
|
this.connecting = false
|
||||||
|
this.url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL): void {
|
||||||
|
if (this.connected || this.connecting) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.url = url
|
||||||
|
this.connecting = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const socketTask = wx.connectSocket({
|
||||||
|
url,
|
||||||
|
})
|
||||||
|
this.socketTask = socketTask
|
||||||
|
|
||||||
|
socketTask.onOpen(() => {
|
||||||
|
this.connected = true
|
||||||
|
this.connecting = false
|
||||||
|
this.callbacks.onOpen()
|
||||||
|
})
|
||||||
|
|
||||||
|
socketTask.onClose((result) => {
|
||||||
|
const reason = result && result.reason ? result.reason : '模拟心率源连接已关闭'
|
||||||
|
this.connected = false
|
||||||
|
this.connecting = false
|
||||||
|
this.socketTask = null
|
||||||
|
this.callbacks.onClose(reason)
|
||||||
|
})
|
||||||
|
|
||||||
|
socketTask.onError((result) => {
|
||||||
|
const message = result && result.errMsg ? result.errMsg : '模拟心率源连接失败'
|
||||||
|
this.connected = false
|
||||||
|
this.connecting = false
|
||||||
|
this.socketTask = null
|
||||||
|
this.callbacks.onError(message)
|
||||||
|
})
|
||||||
|
|
||||||
|
socketTask.onMessage((result) => {
|
||||||
|
if (!result || typeof result.data !== 'string') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = safeParseMessage(result.data)
|
||||||
|
if (!parsed) {
|
||||||
|
this.callbacks.onError('模拟心率消息不是合法 JSON')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const bpm = toHeartRateValue(parsed)
|
||||||
|
if (bpm === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callbacks.onBpm(bpm)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
this.connected = false
|
||||||
|
this.connecting = false
|
||||||
|
this.socketTask = null
|
||||||
|
const message = error && (error as Error).message ? (error as Error).message : '模拟心率源连接创建失败'
|
||||||
|
this.callbacks.onError(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
if (!this.socketTask) {
|
||||||
|
if (this.connected || this.connecting) {
|
||||||
|
this.connected = false
|
||||||
|
this.connecting = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const socketTask = this.socketTask
|
||||||
|
this.socketTask = null
|
||||||
|
this.connected = false
|
||||||
|
this.connecting = false
|
||||||
|
socketTask.close({})
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ type MapPageData = MapEngineViewState & {
|
|||||||
topInsetHeight: number
|
topInsetHeight: number
|
||||||
hudPanelIndex: number
|
hudPanelIndex: number
|
||||||
mockBridgeUrlDraft: string
|
mockBridgeUrlDraft: string
|
||||||
|
mockHeartRateBridgeUrlDraft: string
|
||||||
panelTimerText: string
|
panelTimerText: string
|
||||||
panelMileageText: string
|
panelMileageText: string
|
||||||
panelDistanceValueText: string
|
panelDistanceValueText: string
|
||||||
@@ -31,7 +32,7 @@ type MapPageData = MapEngineViewState & {
|
|||||||
showRightButtonGroups: boolean
|
showRightButtonGroups: boolean
|
||||||
showBottomDebugButton: boolean
|
showBottomDebugButton: boolean
|
||||||
}
|
}
|
||||||
const INTERNAL_BUILD_VERSION = 'map-build-195'
|
const INTERNAL_BUILD_VERSION = 'map-build-196'
|
||||||
const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
|
const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
|
||||||
let mapEngine: MapEngine | null = null
|
let mapEngine: MapEngine | null = null
|
||||||
let stageCanvasAttached = false
|
let stageCanvasAttached = false
|
||||||
@@ -115,6 +116,13 @@ Page({
|
|||||||
mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
||||||
mockCoordText: '--',
|
mockCoordText: '--',
|
||||||
mockSpeedText: '--',
|
mockSpeedText: '--',
|
||||||
|
heartRateSourceMode: 'real',
|
||||||
|
heartRateSourceText: '真实心率',
|
||||||
|
mockHeartRateBridgeConnected: false,
|
||||||
|
mockHeartRateBridgeStatusText: '未连接',
|
||||||
|
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
|
||||||
|
mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
||||||
|
mockHeartRateText: '--',
|
||||||
heartRateScanText: '未扫描',
|
heartRateScanText: '未扫描',
|
||||||
heartRateDiscoveredDevices: [],
|
heartRateDiscoveredDevices: [],
|
||||||
panelSpeedValueText: '0',
|
panelSpeedValueText: '0',
|
||||||
@@ -164,18 +172,25 @@ Page({
|
|||||||
mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
|
mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
|
||||||
onData: (patch) => {
|
onData: (patch) => {
|
||||||
const nextPatch = patch as Partial<MapPageData>
|
const nextPatch = patch as Partial<MapPageData>
|
||||||
|
const nextData: Partial<MapPageData> = {
|
||||||
|
...nextPatch,
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof nextPatch.mockBridgeUrlText === 'string'
|
typeof nextPatch.mockBridgeUrlText === 'string'
|
||||||
&& this.data.mockBridgeUrlDraft === this.data.mockBridgeUrlText
|
&& this.data.mockBridgeUrlDraft === this.data.mockBridgeUrlText
|
||||||
) {
|
) {
|
||||||
this.setData({
|
nextData.mockBridgeUrlDraft = nextPatch.mockBridgeUrlText
|
||||||
...nextPatch,
|
|
||||||
mockBridgeUrlDraft: nextPatch.mockBridgeUrlText,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setData(nextPatch)
|
if (
|
||||||
|
typeof nextPatch.mockHeartRateBridgeUrlText === 'string'
|
||||||
|
&& this.data.mockHeartRateBridgeUrlDraft === this.data.mockHeartRateBridgeUrlText
|
||||||
|
) {
|
||||||
|
nextData.mockHeartRateBridgeUrlDraft = nextPatch.mockHeartRateBridgeUrlText
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setData(nextData)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -202,6 +217,13 @@ Page({
|
|||||||
mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
||||||
mockCoordText: '--',
|
mockCoordText: '--',
|
||||||
mockSpeedText: '--',
|
mockSpeedText: '--',
|
||||||
|
heartRateSourceMode: 'real',
|
||||||
|
heartRateSourceText: '真实心率',
|
||||||
|
mockHeartRateBridgeConnected: false,
|
||||||
|
mockHeartRateBridgeStatusText: '未连接',
|
||||||
|
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
|
||||||
|
mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
||||||
|
mockHeartRateText: '--',
|
||||||
panelSpeedValueText: '0',
|
panelSpeedValueText: '0',
|
||||||
panelTelemetryTone: 'blue',
|
panelTelemetryTone: 'blue',
|
||||||
panelHeartRateZoneNameText: '--',
|
panelHeartRateZoneNameText: '--',
|
||||||
@@ -469,6 +491,42 @@ Page({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleSetRealHeartRateMode() {
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.handleSetRealHeartRateMode()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSetMockHeartRateMode() {
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.handleSetMockHeartRateMode()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleMockHeartRateBridgeUrlInput(event: WechatMiniprogram.Input) {
|
||||||
|
this.setData({
|
||||||
|
mockHeartRateBridgeUrlDraft: event.detail.value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSaveMockHeartRateBridgeUrl() {
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.handleSetMockHeartRateBridgeUrl(this.data.mockHeartRateBridgeUrlDraft)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleConnectMockHeartRateBridge() {
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.handleConnectMockHeartRateBridge()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleDisconnectMockHeartRateBridge() {
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.handleDisconnectMockHeartRateBridge()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
handleConnectHeartRate() {
|
handleConnectHeartRate() {
|
||||||
if (mapEngine) {
|
if (mapEngine) {
|
||||||
mapEngine.handleConnectHeartRate()
|
mapEngine.handleConnectHeartRate()
|
||||||
|
|||||||
@@ -328,15 +328,23 @@
|
|||||||
<text class="info-panel__label">Heart Rate</text>
|
<text class="info-panel__label">Heart Rate</text>
|
||||||
<text class="info-panel__value">{{heartRateStatusText}}</text>
|
<text class="info-panel__value">{{heartRateStatusText}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Heart Source</text>
|
||||||
|
<text class="info-panel__value">{{heartRateSourceText}}</text>
|
||||||
|
</view>
|
||||||
<view class="info-panel__row info-panel__row--stack">
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
<text class="info-panel__label">HR Device</text>
|
<text class="info-panel__label">HR Device</text>
|
||||||
<text class="info-panel__value">{{heartRateDeviceText}}</text>
|
<text class="info-panel__value">{{heartRateDeviceText}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="info-panel__row">
|
<view class="control-row">
|
||||||
|
<view class="control-chip {{heartRateSourceMode === 'real' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetRealHeartRateMode">真实心率</view>
|
||||||
|
<view class="control-chip {{heartRateSourceMode === 'mock' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetMockHeartRateMode">模拟心率</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row" wx:if="{{heartRateSourceMode === 'real'}}">
|
||||||
<text class="info-panel__label">HR Scan</text>
|
<text class="info-panel__label">HR Scan</text>
|
||||||
<text class="info-panel__value">{{heartRateScanText}}</text>
|
<text class="info-panel__value">{{heartRateScanText}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-device-list" wx:if="{{heartRateDiscoveredDevices.length}}">
|
<view class="debug-device-list" wx:if="{{heartRateSourceMode === 'real' && heartRateDiscoveredDevices.length}}">
|
||||||
<view class="debug-device-card" wx:for="{{heartRateDiscoveredDevices}}" wx:key="deviceId">
|
<view class="debug-device-card" wx:for="{{heartRateDiscoveredDevices}}" wx:key="deviceId">
|
||||||
<view class="debug-device-card__main">
|
<view class="debug-device-card__main">
|
||||||
<view class="debug-device-card__title-row">
|
<view class="debug-device-card__title-row">
|
||||||
@@ -348,13 +356,37 @@
|
|||||||
<view class="debug-device-card__action {{item.connected ? 'debug-device-card__action--active' : ''}}" data-device-id="{{item.deviceId}}" bindtap="handleConnectHeartRateDevice">{{item.connected ? '已连接' : '连接'}}</view>
|
<view class="debug-device-card__action {{item.connected ? 'debug-device-card__action--active' : ''}}" data-device-id="{{item.deviceId}}" bindtap="handleConnectHeartRateDevice">{{item.connected ? '已连接' : '连接'}}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="control-row">
|
<view class="control-row" wx:if="{{heartRateSourceMode === 'real'}}">
|
||||||
<view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '心率带已连接' : '连接心率带'}}</view>
|
<view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '心率带已连接' : '连接心率带'}}</view>
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectHeartRate">断开心率带</view>
|
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectHeartRate">断开心率带</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="control-row">
|
<view class="control-row" wx:if="{{heartRateSourceMode === 'real'}}">
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleClearPreferredHeartRateDevice">清除首选</view>
|
<view class="control-chip control-chip--secondary" bindtap="handleClearPreferredHeartRateDevice">清除首选</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack" wx:if="{{heartRateSourceMode === 'mock'}}">
|
||||||
|
<text class="info-panel__label">Mock HR Bridge</text>
|
||||||
|
<text class="info-panel__value">{{mockHeartRateBridgeStatusText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack" wx:if="{{heartRateSourceMode === 'mock'}}">
|
||||||
|
<text class="info-panel__label">Mock HR URL</text>
|
||||||
|
<view class="debug-inline-stack">
|
||||||
|
<input
|
||||||
|
class="debug-input"
|
||||||
|
value="{{mockHeartRateBridgeUrlDraft}}"
|
||||||
|
placeholder="ws://192.168.x.x:17865/mock-gps"
|
||||||
|
bindinput="handleMockHeartRateBridgeUrlInput"
|
||||||
|
/>
|
||||||
|
<view class="control-row control-row--compact">
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleSaveMockHeartRateBridgeUrl">保存地址</view>
|
||||||
|
<view class="control-chip {{mockHeartRateBridgeConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectMockHeartRateBridge">连接模拟心率源</view>
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectMockHeartRateBridge">断开模拟心率源</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row" wx:if="{{heartRateSourceMode === 'mock'}}">
|
||||||
|
<text class="info-panel__label">Mock BPM</text>
|
||||||
|
<text class="info-panel__value">{{mockHeartRateText}}</text>
|
||||||
|
</view>
|
||||||
<view class="info-panel__row">
|
<view class="info-panel__row">
|
||||||
<text class="info-panel__label">Heading Mode</text>
|
<text class="info-panel__label">Heading Mode</text>
|
||||||
<text class="info-panel__value">{{orientationModeText}}</text>
|
<text class="info-panel__value">{{orientationModeText}}</text>
|
||||||
|
|||||||
@@ -73,6 +73,44 @@
|
|||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<div class="group__title">心率模拟</div>
|
||||||
|
<div id="heartRateStatus" class="group__status">心率模拟待命</div>
|
||||||
|
<div id="lastHeartRateStatus" class="group__status">最近发送: --</div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="sendHeartRateOnceBtn" class="btn">发送一次</button>
|
||||||
|
<button id="startHeartRateStreamBtn" class="btn btn--accent">开始连续发送</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="stopHeartRateStreamBtn" class="btn">停止发送</button>
|
||||||
|
<button id="applyHeartRatePresetBtn" class="btn">应用分区样本</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="toggleHeartRateSampleBtn" class="btn">模拟真实样本</button>
|
||||||
|
</div>
|
||||||
|
<label class="field">
|
||||||
|
<span>心率值 (bpm)</span>
|
||||||
|
<input id="heartRateInput" type="number" min="40" max="220" value="120">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>发送频率</span>
|
||||||
|
<select id="heartRateHzSelect">
|
||||||
|
<option value="1" selected>1 Hz</option>
|
||||||
|
<option value="2">2 Hz</option>
|
||||||
|
<option value="4">4 Hz</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>样本模板</span>
|
||||||
|
<select id="heartRateSampleTemplateSelect">
|
||||||
|
<option value="jog" selected>慢跑样本</option>
|
||||||
|
<option value="tempo">节奏跑样本</option>
|
||||||
|
<option value="interval">间歇跑样本</option>
|
||||||
|
<option value="recovery">恢复走样本</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="group">
|
<section class="group">
|
||||||
<div class="group__title">路径回放</div>
|
<div class="group__title">路径回放</div>
|
||||||
<div id="playbackStatus" class="group__status">路径待命</div>
|
<div id="playbackStatus" class="group__status">路径待命</div>
|
||||||
|
|||||||
@@ -33,11 +33,15 @@
|
|||||||
connected: false,
|
connected: false,
|
||||||
socketConnecting: false,
|
socketConnecting: false,
|
||||||
streaming: false,
|
streaming: false,
|
||||||
|
heartRateStreaming: false,
|
||||||
|
heartRateSampleMode: false,
|
||||||
pathEditMode: false,
|
pathEditMode: false,
|
||||||
playbackRunning: false,
|
playbackRunning: false,
|
||||||
playbackTimer: 0,
|
playbackTimer: 0,
|
||||||
streamTimer: 0,
|
streamTimer: 0,
|
||||||
|
heartRateStreamTimer: 0,
|
||||||
lastSentText: '--',
|
lastSentText: '--',
|
||||||
|
lastHeartRateSentText: '--',
|
||||||
lastResourceDetailText: '尚未载入资源',
|
lastResourceDetailText: '尚未载入资源',
|
||||||
lastTrackSourceText: '路径待命',
|
lastTrackSourceText: '路径待命',
|
||||||
currentLatLng: L.latLng(DEFAULT_CENTER[0], DEFAULT_CENTER[1]),
|
currentLatLng: L.latLng(DEFAULT_CENTER[0], DEFAULT_CENTER[1]),
|
||||||
@@ -45,6 +49,7 @@
|
|||||||
currentSegmentIndex: 0,
|
currentSegmentIndex: 0,
|
||||||
currentSegmentProgress: 0,
|
currentSegmentProgress: 0,
|
||||||
lastPlaybackAt: 0,
|
lastPlaybackAt: 0,
|
||||||
|
heartRateSampleStartedAt: 0,
|
||||||
loadedCourse: null,
|
loadedCourse: null,
|
||||||
resourceLoading: false,
|
resourceLoading: false,
|
||||||
}
|
}
|
||||||
@@ -66,6 +71,16 @@
|
|||||||
realtimeStatus: document.getElementById('realtimeStatus'),
|
realtimeStatus: document.getElementById('realtimeStatus'),
|
||||||
lastSendStatus: document.getElementById('lastSendStatus'),
|
lastSendStatus: document.getElementById('lastSendStatus'),
|
||||||
playbackStatus: document.getElementById('playbackStatus'),
|
playbackStatus: document.getElementById('playbackStatus'),
|
||||||
|
heartRateStatus: document.getElementById('heartRateStatus'),
|
||||||
|
lastHeartRateStatus: document.getElementById('lastHeartRateStatus'),
|
||||||
|
sendHeartRateOnceBtn: document.getElementById('sendHeartRateOnceBtn'),
|
||||||
|
startHeartRateStreamBtn: document.getElementById('startHeartRateStreamBtn'),
|
||||||
|
stopHeartRateStreamBtn: document.getElementById('stopHeartRateStreamBtn'),
|
||||||
|
applyHeartRatePresetBtn: document.getElementById('applyHeartRatePresetBtn'),
|
||||||
|
toggleHeartRateSampleBtn: document.getElementById('toggleHeartRateSampleBtn'),
|
||||||
|
heartRateInput: document.getElementById('heartRateInput'),
|
||||||
|
heartRateHzSelect: document.getElementById('heartRateHzSelect'),
|
||||||
|
heartRateSampleTemplateSelect: document.getElementById('heartRateSampleTemplateSelect'),
|
||||||
trackFileInput: document.getElementById('trackFileInput'),
|
trackFileInput: document.getElementById('trackFileInput'),
|
||||||
importTrackBtn: document.getElementById('importTrackBtn'),
|
importTrackBtn: document.getElementById('importTrackBtn'),
|
||||||
connectBtn: document.getElementById('connectBtn'),
|
connectBtn: document.getElementById('connectBtn'),
|
||||||
@@ -144,6 +159,13 @@
|
|||||||
elements.streamBtn.classList.toggle('is-active', state.streaming)
|
elements.streamBtn.classList.toggle('is-active', state.streaming)
|
||||||
elements.streamBtn.disabled = !state.connected || state.streaming
|
elements.streamBtn.disabled = !state.connected || state.streaming
|
||||||
elements.stopStreamBtn.disabled = !state.streaming
|
elements.stopStreamBtn.disabled = !state.streaming
|
||||||
|
elements.sendHeartRateOnceBtn.disabled = !state.connected
|
||||||
|
elements.startHeartRateStreamBtn.textContent = state.heartRateStreaming ? '发送中' : '开始连续发送'
|
||||||
|
elements.startHeartRateStreamBtn.classList.toggle('is-active', state.heartRateStreaming)
|
||||||
|
elements.startHeartRateStreamBtn.disabled = !state.connected || state.heartRateStreaming
|
||||||
|
elements.stopHeartRateStreamBtn.disabled = !state.heartRateStreaming
|
||||||
|
elements.toggleHeartRateSampleBtn.textContent = state.heartRateSampleMode ? '关闭真实样本' : '模拟真实样本'
|
||||||
|
elements.toggleHeartRateSampleBtn.classList.toggle('is-active', state.heartRateSampleMode)
|
||||||
|
|
||||||
elements.togglePathModeBtn.textContent = state.pathEditMode ? '关闭路径编辑' : '开启路径编辑'
|
elements.togglePathModeBtn.textContent = state.pathEditMode ? '关闭路径编辑' : '开启路径编辑'
|
||||||
elements.togglePathModeBtn.classList.toggle('is-active', state.pathEditMode)
|
elements.togglePathModeBtn.classList.toggle('is-active', state.pathEditMode)
|
||||||
@@ -166,6 +188,7 @@
|
|||||||
elements.applyTilesBtn.disabled = state.resourceLoading
|
elements.applyTilesBtn.disabled = state.resourceLoading
|
||||||
elements.resetTilesBtn.disabled = state.resourceLoading
|
elements.resetTilesBtn.disabled = state.resourceLoading
|
||||||
elements.lastSendStatus.textContent = `最近发送: ${state.lastSentText}`
|
elements.lastSendStatus.textContent = `最近发送: ${state.lastSentText}`
|
||||||
|
elements.lastHeartRateStatus.textContent = `最近发送: ${state.lastHeartRateSentText}`
|
||||||
elements.resourceDetail.textContent = state.lastResourceDetailText
|
elements.resourceDetail.textContent = state.lastResourceDetailText
|
||||||
|
|
||||||
if (state.connected && state.streaming) {
|
if (state.connected && state.streaming) {
|
||||||
@@ -178,6 +201,18 @@
|
|||||||
elements.realtimeStatus.textContent = '桥接未连接'
|
elements.realtimeStatus.textContent = '桥接未连接'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.connected && state.heartRateStreaming) {
|
||||||
|
elements.heartRateStatus.textContent = state.heartRateSampleMode
|
||||||
|
? `桥接已连接,正在以 ${elements.heartRateHzSelect.value} Hz 发送真实心率样本`
|
||||||
|
: `桥接已连接,正在以 ${elements.heartRateHzSelect.value} Hz 连续发送心率`
|
||||||
|
} else if (state.connected) {
|
||||||
|
elements.heartRateStatus.textContent = state.heartRateSampleMode ? '真实心率样本待命' : '心率模拟待命'
|
||||||
|
} else if (state.socketConnecting) {
|
||||||
|
elements.heartRateStatus.textContent = '桥接连接中'
|
||||||
|
} else {
|
||||||
|
elements.heartRateStatus.textContent = '桥接未连接'
|
||||||
|
}
|
||||||
|
|
||||||
if (state.playbackRunning) {
|
if (state.playbackRunning) {
|
||||||
elements.playbackStatus.textContent = `路径回放中,速度 ${elements.speedInput.value} km/h`
|
elements.playbackStatus.textContent = `路径回放中,速度 ${elements.speedInput.value} km/h`
|
||||||
} else if (state.pathEditMode) {
|
} else if (state.pathEditMode) {
|
||||||
@@ -212,6 +247,8 @@
|
|||||||
socket.addEventListener('close', () => {
|
socket.addEventListener('close', () => {
|
||||||
state.connected = false
|
state.connected = false
|
||||||
state.socketConnecting = false
|
state.socketConnecting = false
|
||||||
|
stopStream()
|
||||||
|
stopHeartRateStream()
|
||||||
setSocketBadge(false)
|
setSocketBadge(false)
|
||||||
updateUiState()
|
updateUiState()
|
||||||
log('桥接已断开')
|
log('桥接已断开')
|
||||||
@@ -220,6 +257,8 @@
|
|||||||
socket.addEventListener('error', () => {
|
socket.addEventListener('error', () => {
|
||||||
state.connected = false
|
state.connected = false
|
||||||
state.socketConnecting = false
|
state.socketConnecting = false
|
||||||
|
stopStream()
|
||||||
|
stopHeartRateStream()
|
||||||
setSocketBadge(false)
|
setSocketBadge(false)
|
||||||
updateUiState()
|
updateUiState()
|
||||||
log('桥接连接失败')
|
log('桥接连接失败')
|
||||||
@@ -685,6 +724,79 @@
|
|||||||
return Math.max(0.2, (Number(elements.speedInput.value) || 6) / 3.6)
|
return Math.max(0.2, (Number(elements.speedInput.value) || 6) / 3.6)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHeartRateBpm() {
|
||||||
|
return Math.max(40, Math.min(220, Math.round(Number(elements.heartRateInput.value) || 120)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSampleHeartRateBpm() {
|
||||||
|
const now = Date.now()
|
||||||
|
if (!state.heartRateSampleStartedAt) {
|
||||||
|
state.heartRateSampleStartedAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedSeconds = (now - state.heartRateSampleStartedAt) / 1000
|
||||||
|
const template = elements.heartRateSampleTemplateSelect.value || 'jog'
|
||||||
|
|
||||||
|
let cycleSeconds = 360
|
||||||
|
let bpm = 120
|
||||||
|
const jitter = Math.sin(elapsedSeconds * 1.7) * 1.8 + Math.sin(elapsedSeconds * 0.47) * 1.2
|
||||||
|
|
||||||
|
if (template === 'recovery') {
|
||||||
|
cycleSeconds = 300
|
||||||
|
const phase = elapsedSeconds % cycleSeconds
|
||||||
|
if (phase < 80) {
|
||||||
|
bpm = 82 + phase * 0.08
|
||||||
|
} else if (phase < 190) {
|
||||||
|
bpm = 89 + Math.sin((phase - 80) / 20) * 3
|
||||||
|
} else {
|
||||||
|
bpm = 90 - (phase - 190) * 0.06 + Math.sin((phase - 190) / 18) * 2
|
||||||
|
}
|
||||||
|
} else if (template === 'tempo') {
|
||||||
|
cycleSeconds = 320
|
||||||
|
const phase = elapsedSeconds % cycleSeconds
|
||||||
|
if (phase < 50) {
|
||||||
|
bpm = 102 + phase * 0.42
|
||||||
|
} else if (phase < 230) {
|
||||||
|
bpm = 124 + Math.sin((phase - 50) / 14) * 5 + Math.sin((phase - 50) / 36) * 3
|
||||||
|
} else {
|
||||||
|
bpm = 126 - (phase - 230) * 0.18 + Math.sin((phase - 230) / 12) * 3
|
||||||
|
}
|
||||||
|
} else if (template === 'interval') {
|
||||||
|
cycleSeconds = 260
|
||||||
|
const phase = elapsedSeconds % cycleSeconds
|
||||||
|
if (phase < 40) {
|
||||||
|
bpm = 100 + phase * 0.35
|
||||||
|
} else {
|
||||||
|
const wavePhase = phase - 40
|
||||||
|
const intervalCycle = wavePhase % 44
|
||||||
|
if (intervalCycle < 20) {
|
||||||
|
bpm = 140 + intervalCycle * 1.2
|
||||||
|
} else if (intervalCycle < 32) {
|
||||||
|
bpm = 164 - (intervalCycle - 20) * 0.45
|
||||||
|
} else {
|
||||||
|
bpm = 158 - (intervalCycle - 32) * 2.7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const phase = elapsedSeconds % cycleSeconds
|
||||||
|
if (phase < 60) {
|
||||||
|
bpm = 96 + phase * 0.35
|
||||||
|
} else if (phase < 150) {
|
||||||
|
bpm = 118 + Math.sin((phase - 60) / 18) * 6
|
||||||
|
} else if (phase < 240) {
|
||||||
|
bpm = 138 + Math.sin((phase - 150) / 10) * 9
|
||||||
|
} else if (phase < 300) {
|
||||||
|
bpm = 158 + Math.sin((phase - 240) / 7) * 8
|
||||||
|
} else {
|
||||||
|
bpm = 124 - (phase - 300) * 0.22 + Math.sin((phase - 300) / 15) * 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextBpm = Math.max(72, Math.min(182, Math.round(bpm + jitter)))
|
||||||
|
elements.heartRateInput.value = String(nextBpm)
|
||||||
|
return nextBpm
|
||||||
|
}
|
||||||
|
|
||||||
function sendCurrentPoint() {
|
function sendCurrentPoint() {
|
||||||
if (!state.socket || state.socket.readyState !== WebSocket.OPEN) {
|
if (!state.socket || state.socket.readyState !== WebSocket.OPEN) {
|
||||||
log('未连接桥接,无法发送')
|
log('未连接桥接,无法发送')
|
||||||
@@ -705,6 +817,22 @@
|
|||||||
updateUiState()
|
updateUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendCurrentHeartRate() {
|
||||||
|
if (!state.socket || state.socket.readyState !== WebSocket.OPEN) {
|
||||||
|
log('未连接桥接,无法发送心率')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: 'mock_heart_rate',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
bpm: state.heartRateSampleMode ? getSampleHeartRateBpm() : getHeartRateBpm(),
|
||||||
|
}
|
||||||
|
state.socket.send(JSON.stringify(payload))
|
||||||
|
state.lastHeartRateSentText = `${formatClockTime(payload.timestamp)} @ ${payload.bpm} bpm`
|
||||||
|
updateUiState()
|
||||||
|
}
|
||||||
|
|
||||||
function startStream() {
|
function startStream() {
|
||||||
stopStream()
|
stopStream()
|
||||||
state.streaming = true
|
state.streaming = true
|
||||||
@@ -725,6 +853,53 @@
|
|||||||
updateUiState()
|
updateUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startHeartRateStream() {
|
||||||
|
stopHeartRateStream()
|
||||||
|
state.heartRateStreaming = true
|
||||||
|
if (state.heartRateSampleMode && !state.heartRateSampleStartedAt) {
|
||||||
|
state.heartRateSampleStartedAt = Date.now()
|
||||||
|
}
|
||||||
|
const intervalMs = Math.max(150, 1000 / (Number(elements.heartRateHzSelect.value) || 1))
|
||||||
|
sendCurrentHeartRate()
|
||||||
|
state.heartRateStreamTimer = window.setInterval(sendCurrentHeartRate, intervalMs)
|
||||||
|
updateUiState()
|
||||||
|
log(`开始连续发送心率 (${Math.round(1000 / intervalMs)} Hz)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHeartRateStream() {
|
||||||
|
state.heartRateStreaming = false
|
||||||
|
if (state.heartRateStreamTimer) {
|
||||||
|
window.clearInterval(state.heartRateStreamTimer)
|
||||||
|
state.heartRateStreamTimer = 0
|
||||||
|
log('已停止连续发送心率')
|
||||||
|
}
|
||||||
|
updateUiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHeartRatePreset() {
|
||||||
|
const sampleBpm = [88, 102, 118, 136, 154, 170]
|
||||||
|
const current = getHeartRateBpm()
|
||||||
|
let nextIndex = sampleBpm.findIndex((value) => value > current)
|
||||||
|
if (nextIndex === -1) {
|
||||||
|
nextIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.heartRateInput.value = String(sampleBpm[nextIndex])
|
||||||
|
log(`已应用心率分区样本: ${sampleBpm[nextIndex]} bpm`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHeartRateSampleMode() {
|
||||||
|
state.heartRateSampleMode = !state.heartRateSampleMode
|
||||||
|
state.heartRateSampleStartedAt = state.heartRateSampleMode ? Date.now() : 0
|
||||||
|
if (state.heartRateSampleMode) {
|
||||||
|
const bpm = getSampleHeartRateBpm()
|
||||||
|
log(`已开启真实心率样本 (${elements.heartRateSampleTemplateSelect.value || 'jog'}): ${bpm} bpm`)
|
||||||
|
} else {
|
||||||
|
log('已关闭真实心率样本')
|
||||||
|
}
|
||||||
|
updateUiState()
|
||||||
|
}
|
||||||
|
|
||||||
function syncPathLine() {
|
function syncPathLine() {
|
||||||
pathLine.setLatLngs(pathPoints)
|
pathLine.setLatLngs(pathPoints)
|
||||||
elements.pathCountText.textContent = String(pathPoints.length)
|
elements.pathCountText.textContent = String(pathPoints.length)
|
||||||
@@ -1128,6 +1303,14 @@
|
|||||||
})
|
})
|
||||||
elements.streamBtn.addEventListener('click', startStream)
|
elements.streamBtn.addEventListener('click', startStream)
|
||||||
elements.stopStreamBtn.addEventListener('click', stopStream)
|
elements.stopStreamBtn.addEventListener('click', stopStream)
|
||||||
|
elements.sendHeartRateOnceBtn.addEventListener('click', () => {
|
||||||
|
sendCurrentHeartRate()
|
||||||
|
log('已发送当前心率')
|
||||||
|
})
|
||||||
|
elements.startHeartRateStreamBtn.addEventListener('click', startHeartRateStream)
|
||||||
|
elements.stopHeartRateStreamBtn.addEventListener('click', stopHeartRateStream)
|
||||||
|
elements.applyHeartRatePresetBtn.addEventListener('click', applyHeartRatePreset)
|
||||||
|
elements.toggleHeartRateSampleBtn.addEventListener('click', toggleHeartRateSampleMode)
|
||||||
elements.togglePathModeBtn.addEventListener('click', () => {
|
elements.togglePathModeBtn.addEventListener('click', () => {
|
||||||
state.pathEditMode = !state.pathEditMode
|
state.pathEditMode = !state.pathEditMode
|
||||||
elements.pathHint.textContent = state.pathEditMode
|
elements.pathHint.textContent = state.pathEditMode
|
||||||
|
|||||||
@@ -2,24 +2,30 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
body {
|
body {
|
||||||
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "Segoe UI", "PingFang SC", sans-serif;
|
font-family: "Segoe UI", "PingFang SC", sans-serif;
|
||||||
background: #edf3ea;
|
background: #edf3ea;
|
||||||
color: #163126;
|
color: #163126;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 400px 1fr;
|
grid-template-columns: 400px 1fr;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
|
height: 100vh;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: rgba(250, 252, 248, 0.96);
|
background: rgba(250, 252, 248, 0.96);
|
||||||
border-right: 1px solid rgba(22, 49, 38, 0.08);
|
border-right: 1px solid rgba(22, 49, 38, 0.08);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel__header h1 {
|
.panel__header h1 {
|
||||||
@@ -221,7 +227,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.map-shell {
|
.map-shell {
|
||||||
min-height: 100vh;
|
position: relative;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#map {
|
#map {
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ function isMockGpsPayload(payload) {
|
|||||||
&& Number.isFinite(payload.lon)
|
&& Number.isFinite(payload.lon)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMockHeartRatePayload(payload) {
|
||||||
|
return payload
|
||||||
|
&& payload.type === 'mock_heart_rate'
|
||||||
|
&& Number.isFinite(payload.bpm)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleProxyRequest(request, response) {
|
async function handleProxyRequest(request, response) {
|
||||||
const requestUrl = new URL(request.url || '/', `http://127.0.0.1:${PORT}`)
|
const requestUrl = new URL(request.url || '/', `http://127.0.0.1:${PORT}`)
|
||||||
const targetUrl = requestUrl.searchParams.get('url')
|
const targetUrl = requestUrl.searchParams.get('url')
|
||||||
@@ -111,19 +117,25 @@ wss.on('connection', (socket) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isMockGpsPayload(parsed)) {
|
if (!isMockGpsPayload(parsed) && !isMockHeartRatePayload(parsed)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const serialized = JSON.stringify({
|
const serialized = isMockGpsPayload(parsed)
|
||||||
type: 'mock_gps',
|
? JSON.stringify({
|
||||||
timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
|
type: 'mock_gps',
|
||||||
lat: Number(parsed.lat),
|
timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
|
||||||
lon: Number(parsed.lon),
|
lat: Number(parsed.lat),
|
||||||
accuracyMeters: Number.isFinite(parsed.accuracyMeters) ? Number(parsed.accuracyMeters) : 6,
|
lon: Number(parsed.lon),
|
||||||
speedMps: Number.isFinite(parsed.speedMps) ? Number(parsed.speedMps) : 0,
|
accuracyMeters: Number.isFinite(parsed.accuracyMeters) ? Number(parsed.accuracyMeters) : 6,
|
||||||
headingDeg: Number.isFinite(parsed.headingDeg) ? Number(parsed.headingDeg) : 0,
|
speedMps: Number.isFinite(parsed.speedMps) ? Number(parsed.speedMps) : 0,
|
||||||
})
|
headingDeg: Number.isFinite(parsed.headingDeg) ? Number(parsed.headingDeg) : 0,
|
||||||
|
})
|
||||||
|
: JSON.stringify({
|
||||||
|
type: 'mock_heart_rate',
|
||||||
|
timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
|
||||||
|
bpm: Math.max(1, Math.round(Number(parsed.bpm))),
|
||||||
|
})
|
||||||
|
|
||||||
wss.clients.forEach((client) => {
|
wss.clients.forEach((client) => {
|
||||||
if (client.readyState === client.OPEN) {
|
if (client.readyState === client.OPEN) {
|
||||||
|
|||||||
Reference in New Issue
Block a user