Add mock heart rate simulator flow

This commit is contained in:
2026-03-24 18:28:21 +08:00
parent 0ccf7daf50
commit 3f6563c992
9 changed files with 892 additions and 29 deletions

View File

@@ -73,6 +73,44 @@
</label>
</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">
<div class="group__title">路径回放</div>
<div id="playbackStatus" class="group__status">路径待命</div>

View File

@@ -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

View File

@@ -2,24 +2,30 @@
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
font-family: "Segoe UI", "PingFang SC", sans-serif;
background: #edf3ea;
color: #163126;
overflow: hidden;
}
.layout {
display: grid;
grid-template-columns: 400px 1fr;
min-height: 100vh;
height: 100vh;
overflow: hidden;
}
.panel {
height: 100vh;
padding: 20px;
background: rgba(250, 252, 248, 0.96);
border-right: 1px solid rgba(22, 49, 38, 0.08);
overflow-y: auto;
overscroll-behavior: contain;
}
.panel__header h1 {
@@ -221,7 +227,9 @@ body {
}
.map-shell {
min-height: 100vh;
position: relative;
height: 100vh;
overflow: hidden;
}
#map {