|
|
@@ -33,11 +33,15 @@
|
|
|
connected: false,
|
|
|
socketConnecting: false,
|
|
|
streaming: false,
|
|
|
+ heartRateStreaming: false,
|
|
|
+ heartRateSampleMode: false,
|
|
|
pathEditMode: false,
|
|
|
playbackRunning: false,
|
|
|
playbackTimer: 0,
|
|
|
streamTimer: 0,
|
|
|
+ heartRateStreamTimer: 0,
|
|
|
lastSentText: '--',
|
|
|
+ lastHeartRateSentText: '--',
|
|
|
lastResourceDetailText: '尚未载入资源',
|
|
|
lastTrackSourceText: '路径待命',
|
|
|
currentLatLng: L.latLng(DEFAULT_CENTER[0], DEFAULT_CENTER[1]),
|
|
|
@@ -45,6 +49,7 @@
|
|
|
currentSegmentIndex: 0,
|
|
|
currentSegmentProgress: 0,
|
|
|
lastPlaybackAt: 0,
|
|
|
+ heartRateSampleStartedAt: 0,
|
|
|
loadedCourse: null,
|
|
|
resourceLoading: false,
|
|
|
}
|
|
|
@@ -66,6 +71,16 @@
|
|
|
realtimeStatus: document.getElementById('realtimeStatus'),
|
|
|
lastSendStatus: document.getElementById('lastSendStatus'),
|
|
|
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'),
|
|
|
importTrackBtn: document.getElementById('importTrackBtn'),
|
|
|
connectBtn: document.getElementById('connectBtn'),
|
|
|
@@ -144,6 +159,13 @@
|
|
|
elements.streamBtn.classList.toggle('is-active', state.streaming)
|
|
|
elements.streamBtn.disabled = !state.connected || 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.classList.toggle('is-active', state.pathEditMode)
|
|
|
@@ -166,6 +188,7 @@
|
|
|
elements.applyTilesBtn.disabled = state.resourceLoading
|
|
|
elements.resetTilesBtn.disabled = state.resourceLoading
|
|
|
elements.lastSendStatus.textContent = `最近发送: ${state.lastSentText}`
|
|
|
+ elements.lastHeartRateStatus.textContent = `最近发送: ${state.lastHeartRateSentText}`
|
|
|
elements.resourceDetail.textContent = state.lastResourceDetailText
|
|
|
|
|
|
if (state.connected && state.streaming) {
|
|
|
@@ -178,6 +201,18 @@
|
|
|
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) {
|
|
|
elements.playbackStatus.textContent = `路径回放中,速度 ${elements.speedInput.value} km/h`
|
|
|
} else if (state.pathEditMode) {
|
|
|
@@ -212,6 +247,8 @@
|
|
|
socket.addEventListener('close', () => {
|
|
|
state.connected = false
|
|
|
state.socketConnecting = false
|
|
|
+ stopStream()
|
|
|
+ stopHeartRateStream()
|
|
|
setSocketBadge(false)
|
|
|
updateUiState()
|
|
|
log('桥接已断开')
|
|
|
@@ -220,6 +257,8 @@
|
|
|
socket.addEventListener('error', () => {
|
|
|
state.connected = false
|
|
|
state.socketConnecting = false
|
|
|
+ stopStream()
|
|
|
+ stopHeartRateStream()
|
|
|
setSocketBadge(false)
|
|
|
updateUiState()
|
|
|
log('桥接连接失败')
|
|
|
@@ -685,6 +724,79 @@
|
|
|
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() {
|
|
|
if (!state.socket || state.socket.readyState !== WebSocket.OPEN) {
|
|
|
log('未连接桥接,无法发送')
|
|
|
@@ -705,6 +817,22 @@
|
|
|
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() {
|
|
|
stopStream()
|
|
|
state.streaming = true
|
|
|
@@ -725,6 +853,53 @@
|
|
|
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() {
|
|
|
pathLine.setLatLngs(pathPoints)
|
|
|
elements.pathCountText.textContent = String(pathPoints.length)
|
|
|
@@ -1128,6 +1303,14 @@
|
|
|
})
|
|
|
elements.streamBtn.addEventListener('click', startStream)
|
|
|
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', () => {
|
|
|
state.pathEditMode = !state.pathEditMode
|
|
|
elements.pathHint.textContent = state.pathEditMode
|