Files
cmr-mini/tools/mock-gps-sim/public/simulator.js

1981 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
const DEFAULT_CENTER = [31.2304, 121.4737]
const DEFAULT_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
const DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
const PROXY_BASE_URL = `${location.origin}/proxy?url=`
const GPS_WS_URL = `ws://${location.hostname}:17865/mock-gps`
const HEART_RATE_WS_URL = `ws://${location.hostname}:17865/mock-hr`
const DEBUG_LOG_WS_URL = `ws://${location.hostname}:17865/debug-log`
const DEFAULT_GATEWAY_BRIDGE_URL = 'ws://127.0.0.1:18080/ws'
const LEGACY_GATEWAY_BRIDGE_URLS = new Set([
'ws://127.0.0.1:8080/ws',
'ws://localhost:8080/ws',
])
const BRIDGE_CONFIG_STORAGE_KEY = 'mock-gps-sim.bridge-config'
const BRIDGE_PRESETS_STORAGE_KEY = 'mock-gps-sim.bridge-presets'
const MAX_DEBUG_LOG_LINES = 400
const map = L.map('map').setView(DEFAULT_CENTER, 16)
let tileLayer = createTileLayer(DEFAULT_TILE_URL, {
maxZoom: 20,
attribution: '© OpenStreetMap',
}).addTo(map)
const liveMarker = L.circleMarker(DEFAULT_CENTER, {
radius: 11,
color: '#ffffff',
weight: 3,
fillColor: '#ff2f92',
fillOpacity: 0.94,
}).addTo(map)
const pathLine = L.polyline([], {
color: '#0ea5a4',
weight: 4,
opacity: 0.9,
}).addTo(map)
const courseLayer = L.layerGroup().addTo(map)
const pathMarkers = []
const pathPoints = []
const state = {
socket: null,
heartRateSocket: null,
debugSocket: null,
connected: false,
heartRateConnected: false,
debugConnected: false,
socketConnecting: false,
heartRateSocketConnecting: false,
debugSocketConnecting: 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]),
headingDeg: 0,
currentSegmentIndex: 0,
currentSegmentProgress: 0,
lastPlaybackAt: 0,
heartRateSampleStartedAt: 0,
loadedCourse: null,
resourceLoading: false,
bridgeEnabled: false,
bridgeConnected: false,
bridgeAuthenticated: false,
bridgeTargetText: '--',
bridgeLastStatusText: '--',
bridgeConfigSaving: false,
bridgePresets: [],
debugLogEntries: [],
debugLogScopeFilter: 'all',
debugLogPanelMinimized: false,
}
const elements = {
socketStatus: document.getElementById('socketStatus'),
configUrlInput: document.getElementById('configUrlInput'),
loadConfigBtn: document.getElementById('loadConfigBtn'),
fitCourseBtn: document.getElementById('fitCourseBtn'),
tileUrlInput: document.getElementById('tileUrlInput'),
applyTilesBtn: document.getElementById('applyTilesBtn'),
resetTilesBtn: document.getElementById('resetTilesBtn'),
courseUrlInput: document.getElementById('courseUrlInput'),
loadCourseBtn: document.getElementById('loadCourseBtn'),
clearCourseBtn: document.getElementById('clearCourseBtn'),
resourceStatus: document.getElementById('resourceStatus'),
resourceDetail: document.getElementById('resourceDetail'),
courseJumpList: document.getElementById('courseJumpList'),
realtimeStatus: document.getElementById('realtimeStatus'),
lastSendStatus: document.getElementById('lastSendStatus'),
gatewayBridgeStatus: document.getElementById('gatewayBridgeStatus'),
gatewayBridgeTarget: document.getElementById('gatewayBridgeTarget'),
gatewayBridgeLast: document.getElementById('gatewayBridgeLast'),
gatewayBridgePresetSelect: document.getElementById('gatewayBridgePresetSelect'),
gatewayBridgePresetNameInput: document.getElementById('gatewayBridgePresetNameInput'),
applyGatewayBridgePresetBtn: document.getElementById('applyGatewayBridgePresetBtn'),
saveGatewayBridgePresetBtn: document.getElementById('saveGatewayBridgePresetBtn'),
deleteGatewayBridgePresetBtn: document.getElementById('deleteGatewayBridgePresetBtn'),
gatewayBridgeEnabledInput: document.getElementById('gatewayBridgeEnabledInput'),
gatewayBridgeUrlInput: document.getElementById('gatewayBridgeUrlInput'),
gatewayBridgeTokenInput: document.getElementById('gatewayBridgeTokenInput'),
gatewayBridgeChannelIdInput: document.getElementById('gatewayBridgeChannelIdInput'),
gatewayBridgeDeviceIdInput: document.getElementById('gatewayBridgeDeviceIdInput'),
gatewayBridgeGroupIdInput: document.getElementById('gatewayBridgeGroupIdInput'),
gatewayBridgeSourceIdInput: document.getElementById('gatewayBridgeSourceIdInput'),
gatewayBridgeSourceModeInput: document.getElementById('gatewayBridgeSourceModeInput'),
applyGatewayBridgeConfigBtn: document.getElementById('applyGatewayBridgeConfigBtn'),
reloadGatewayBridgeConfigBtn: document.getElementById('reloadGatewayBridgeConfigBtn'),
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'),
sendOnceBtn: document.getElementById('sendOnceBtn'),
streamBtn: document.getElementById('streamBtn'),
stopStreamBtn: document.getElementById('stopStreamBtn'),
togglePathModeBtn: document.getElementById('togglePathModeBtn'),
clearPathBtn: document.getElementById('clearPathBtn'),
fitPathBtn: document.getElementById('fitPathBtn'),
playPathBtn: document.getElementById('playPathBtn'),
pausePathBtn: document.getElementById('pausePathBtn'),
hzSelect: document.getElementById('hzSelect'),
accuracyInput: document.getElementById('accuracyInput'),
speedInput: document.getElementById('speedInput'),
loopPathInput: document.getElementById('loopPathInput'),
pathHint: document.getElementById('pathHint'),
latText: document.getElementById('latText'),
lonText: document.getElementById('lonText'),
headingText: document.getElementById('headingText'),
pathCountText: document.getElementById('pathCountText'),
log: document.getElementById('log'),
debugLog: document.getElementById('debugLog'),
debugLogMeta: document.getElementById('debugLogMeta'),
clearDebugLogBtn: document.getElementById('clearDebugLogBtn'),
debugLogScopeFilter: document.getElementById('debugLogScopeFilter'),
floatingDebugLogPanel: document.getElementById('floatingDebugLogPanel'),
toggleDebugLogPanelBtn: document.getElementById('toggleDebugLogPanelBtn'),
topGpsStatus: document.getElementById('topGpsStatus'),
topHrStatus: document.getElementById('topHrStatus'),
topLoggerStatus: document.getElementById('topLoggerStatus'),
topGatewayStatus: document.getElementById('topGatewayStatus'),
summaryResourceText: document.getElementById('summaryResourceText'),
summaryGpsSendText: document.getElementById('summaryGpsSendText'),
summaryHrSendText: document.getElementById('summaryHrSendText'),
summaryPathText: document.getElementById('summaryPathText'),
summaryGatewayText: document.getElementById('summaryGatewayText'),
}
elements.configUrlInput.value = DEFAULT_CONFIG_URL
function createTileLayer(urlTemplate, extraOptions) {
return L.tileLayer(urlTemplate, Object.assign({
maxZoom: 20,
attribution: 'Custom Map',
}, extraOptions || {}))
}
function log(message) {
const time = new Date().toLocaleTimeString()
elements.log.textContent = `[${time}] ${message}\n` + elements.log.textContent
}
function logDebug(entry) {
if (!elements.debugLog) {
return
}
const normalized = {
timestamp: entry.timestamp || Date.now(),
scope: String(entry.scope || 'app'),
level: String(entry.level || 'info'),
message: String(entry.message || ''),
payload: entry.payload && typeof entry.payload === 'object' ? entry.payload : null,
}
state.debugLogEntries.unshift(normalized)
if (state.debugLogEntries.length > MAX_DEBUG_LOG_LINES) {
state.debugLogEntries = state.debugLogEntries.slice(0, MAX_DEBUG_LOG_LINES)
}
renderDebugScopeOptions()
renderDebugLog()
}
function clearDebugLog() {
state.debugLogEntries = []
renderDebugScopeOptions()
renderDebugLog()
}
function renderDebugScopeOptions() {
if (!elements.debugLogScopeFilter) {
return
}
const staticOptions = ['all', 'logger', 'gps-logo', 'gps', 'heart-rate', 'track', 'compass', 'h5', 'content-card', 'gateway']
const seenScopes = new Set(staticOptions)
state.debugLogEntries.forEach((entry) => {
if (entry.scope) {
seenScopes.add(entry.scope)
}
})
const options = Array.from(seenScopes)
const currentValue = options.includes(state.debugLogScopeFilter) ? state.debugLogScopeFilter : 'all'
elements.debugLogScopeFilter.innerHTML = options
.map((scope) => `<option value="${scope}">${scope === 'all' ? '全部' : scope}</option>`)
.join('')
elements.debugLogScopeFilter.value = currentValue
state.debugLogScopeFilter = currentValue
}
function renderDebugLog() {
if (!elements.debugLog) {
return
}
const filteredEntries = state.debugLogEntries.filter((entry) => {
return state.debugLogScopeFilter === 'all' || entry.scope === state.debugLogScopeFilter
})
if (elements.debugLogMeta) {
const scopeLabel = state.debugLogScopeFilter === 'all' ? '全部' : state.debugLogScopeFilter
elements.debugLogMeta.textContent = `${scopeLabel} · ${filteredEntries.length}`
}
elements.debugLog.textContent = filteredEntries
.map((entry) => {
const time = new Date(entry.timestamp || Date.now()).toLocaleTimeString()
const level = String(entry.level || 'info').toUpperCase()
const payloadText = entry.payload ? ` ${JSON.stringify(entry.payload)}` : ''
return `[${time}] [${entry.scope}] [${level}] ${entry.message}${payloadText}`
})
.join('\n')
}
function updateDebugLogPanelState() {
if (!elements.floatingDebugLogPanel || !elements.toggleDebugLogPanelBtn) {
return
}
elements.floatingDebugLogPanel.classList.toggle('is-minimized', state.debugLogPanelMinimized)
elements.toggleDebugLogPanelBtn.textContent = state.debugLogPanelMinimized ? '展开' : '缩小'
}
function setResourceStatus(message, tone) {
elements.resourceStatus.textContent = message
elements.resourceStatus.className = 'hint'
if (tone === 'ok') {
elements.resourceStatus.classList.add('hint--ok')
} else if (tone === 'warn') {
elements.resourceStatus.classList.add('hint--warn')
}
}
function updateReadout() {
elements.latText.textContent = state.currentLatLng.lat.toFixed(6)
elements.lonText.textContent = state.currentLatLng.lng.toFixed(6)
elements.headingText.textContent = `${Math.round(state.headingDeg)}°`
elements.pathCountText.textContent = String(pathPoints.length)
liveMarker.setLatLng(state.currentLatLng)
}
function setSocketBadge(connected) {
elements.socketStatus.textContent = connected ? '已连接' : '未连接'
elements.socketStatus.className = connected ? 'badge badge--ok' : 'badge badge--muted'
}
function setConnectionValue(element, text, tone) {
if (!element) {
return
}
element.textContent = text
element.classList.remove('is-ok', 'is-warn')
if (tone === 'ok') {
element.classList.add('is-ok')
} else if (tone === 'warn') {
element.classList.add('is-warn')
}
}
function formatClockTime(timestamp) {
if (!timestamp) {
return '--'
}
return new Date(timestamp).toLocaleTimeString()
}
function updateUiState() {
elements.connectBtn.textContent = state.connected ? '桥接已连接' : state.socketConnecting ? '连接中...' : '连接桥接'
elements.connectBtn.classList.toggle('is-active', state.connected)
elements.connectBtn.disabled = state.connected || state.socketConnecting
elements.sendOnceBtn.disabled = !state.connected
elements.streamBtn.textContent = state.streaming ? '发送中' : '开始连续发送'
elements.streamBtn.classList.toggle('is-active', state.streaming)
elements.streamBtn.disabled = !state.connected || state.streaming
elements.stopStreamBtn.disabled = !state.streaming
elements.sendHeartRateOnceBtn.disabled = !state.heartRateConnected
elements.startHeartRateStreamBtn.textContent = state.heartRateStreaming ? '发送中' : '开始连续发送'
elements.startHeartRateStreamBtn.classList.toggle('is-active', state.heartRateStreaming)
elements.startHeartRateStreamBtn.disabled = !state.heartRateConnected || 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)
elements.importTrackBtn.disabled = state.resourceLoading
elements.clearPathBtn.textContent = pathPoints.length ? `清空路径 (${pathPoints.length})` : '清空路径'
elements.clearPathBtn.disabled = pathPoints.length === 0
elements.fitPathBtn.disabled = pathPoints.length < 2
elements.playPathBtn.textContent = state.playbackRunning ? '回放中' : '开始回放'
elements.playPathBtn.classList.toggle('is-active', state.playbackRunning)
elements.playPathBtn.disabled = pathPoints.length < 2 || state.playbackRunning
elements.pausePathBtn.disabled = !state.playbackRunning
elements.fitCourseBtn.disabled = !state.loadedCourse
elements.clearCourseBtn.disabled = !state.loadedCourse
elements.loadConfigBtn.textContent = state.resourceLoading ? '载入中...' : '载入配置'
elements.loadConfigBtn.disabled = state.resourceLoading
elements.loadCourseBtn.textContent = state.resourceLoading ? '载入中...' : '载入控制点'
elements.loadCourseBtn.disabled = state.resourceLoading
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
elements.gatewayBridgeTarget.textContent = `目标设备: ${state.bridgeTargetText}`
elements.gatewayBridgeLast.textContent = `最近状态: ${state.bridgeLastStatusText}`
elements.applyGatewayBridgeConfigBtn.disabled = state.bridgeConfigSaving
elements.reloadGatewayBridgeConfigBtn.disabled = state.bridgeConfigSaving
elements.applyGatewayBridgePresetBtn.disabled = state.bridgeConfigSaving || !elements.gatewayBridgePresetSelect.value
elements.saveGatewayBridgePresetBtn.disabled = state.bridgeConfigSaving
elements.deleteGatewayBridgePresetBtn.disabled = state.bridgeConfigSaving || !elements.gatewayBridgePresetSelect.value
if (!state.bridgeEnabled) {
elements.gatewayBridgeStatus.textContent = '未启用'
} else if (state.bridgeConnected && state.bridgeAuthenticated) {
elements.gatewayBridgeStatus.textContent = '已连接并已认证'
} else if (state.bridgeConnected) {
elements.gatewayBridgeStatus.textContent = '已连接,等待认证'
} else {
elements.gatewayBridgeStatus.textContent = '已启用,未连接'
}
if (state.connected && state.streaming) {
elements.realtimeStatus.textContent = `桥接已连接,正在以 ${elements.hzSelect.value} Hz 连续发送`
} else if (state.connected) {
elements.realtimeStatus.textContent = '桥接已连接,待命中'
} else if (state.socketConnecting) {
elements.realtimeStatus.textContent = '桥接连接中'
} else {
elements.realtimeStatus.textContent = '桥接未连接'
}
if (state.heartRateConnected && state.heartRateStreaming) {
elements.heartRateStatus.textContent = state.heartRateSampleMode
? `桥接已连接,正在以 ${elements.heartRateHzSelect.value} Hz 发送真实心率样本`
: `桥接已连接,正在以 ${elements.heartRateHzSelect.value} Hz 连续发送心率`
} else if (state.heartRateConnected) {
elements.heartRateStatus.textContent = state.heartRateSampleMode ? '真实心率样本待命' : '心率模拟待命'
} else if (state.heartRateSocketConnecting) {
elements.heartRateStatus.textContent = '桥接连接中'
} else {
elements.heartRateStatus.textContent = '桥接未连接'
}
if (state.playbackRunning) {
elements.playbackStatus.textContent = `路径回放中,速度 ${elements.speedInput.value} km/h`
} else if (state.pathEditMode) {
elements.playbackStatus.textContent = '路径编辑中,点击地图追加路径点'
} else if (pathPoints.length >= 2) {
elements.playbackStatus.textContent = `${state.lastTrackSourceText},共 ${pathPoints.length} 个路径点`
} else {
elements.playbackStatus.textContent = '路径待命'
}
setConnectionValue(
elements.topGpsStatus,
state.connected ? (state.streaming ? '发送中' : '已连接') : state.socketConnecting ? '连接中' : '未连接',
state.connected ? 'ok' : state.socketConnecting ? 'warn' : null
)
setConnectionValue(
elements.topHrStatus,
state.heartRateConnected ? (state.heartRateStreaming ? '发送中' : '已连接') : state.heartRateSocketConnecting ? '连接中' : '未连接',
state.heartRateConnected ? 'ok' : state.heartRateSocketConnecting ? 'warn' : null
)
setConnectionValue(
elements.topLoggerStatus,
state.debugConnected ? '已连接' : state.debugSocketConnecting ? '连接中' : '未连接',
state.debugConnected ? 'ok' : state.debugSocketConnecting ? 'warn' : null
)
setConnectionValue(
elements.topGatewayStatus,
!state.bridgeEnabled ? '未启用' : state.bridgeConnected && state.bridgeAuthenticated ? '已认证' : state.bridgeConnected ? '待认证' : '未连接',
state.bridgeConnected && state.bridgeAuthenticated ? 'ok' : state.bridgeEnabled ? 'warn' : null
)
if (elements.summaryResourceText) {
elements.summaryResourceText.textContent = state.resourceLoading ? '载入中' : state.loadedCourse ? '已载入' : '未载入'
}
if (elements.summaryGpsSendText) {
elements.summaryGpsSendText.textContent = state.connected ? (state.streaming ? `${elements.hzSelect.value} Hz` : '待命') : '未连接'
}
if (elements.summaryHrSendText) {
elements.summaryHrSendText.textContent = state.heartRateConnected ? (state.heartRateStreaming ? `${elements.heartRateHzSelect.value} Hz` : '待命') : '未连接'
}
if (elements.summaryPathText) {
elements.summaryPathText.textContent = state.playbackRunning ? '回放中' : state.pathEditMode ? '编辑中' : pathPoints.length >= 2 ? `${pathPoints.length}` : '待命'
}
if (elements.summaryGatewayText) {
elements.summaryGatewayText.textContent = !state.bridgeEnabled ? '未启用' : state.bridgeConnected && state.bridgeAuthenticated ? '已认证' : state.bridgeConnected ? '待认证' : '未连接'
}
}
function bridgeConfigFromServerPayload(payload) {
const config = payload && payload.config ? payload.config : {}
return {
enabled: Boolean(config.enabled),
url: normalizeGatewayBridgeUrl(typeof config.url === 'string' ? config.url : ''),
token: typeof config.token === 'string' ? config.token : '',
channelId: typeof config.channelId === 'string' ? config.channelId : '',
deviceId: typeof config.deviceId === 'string' ? config.deviceId : '',
groupId: typeof config.groupId === 'string' ? config.groupId : '',
sourceId: typeof config.sourceId === 'string' ? config.sourceId : '',
sourceMode: typeof config.sourceMode === 'string' ? config.sourceMode : 'mock',
}
}
function normalizeGatewayBridgeUrl(value) {
const next = String(value || '').trim()
if (!next) {
return DEFAULT_GATEWAY_BRIDGE_URL
}
if (LEGACY_GATEWAY_BRIDGE_URLS.has(next)) {
return DEFAULT_GATEWAY_BRIDGE_URL
}
return next
}
function getBridgeConfigDraft() {
try {
const raw = window.localStorage.getItem(BRIDGE_CONFIG_STORAGE_KEY)
if (!raw) {
return null
}
const parsed = JSON.parse(raw)
return {
enabled: Boolean(parsed.enabled),
url: normalizeGatewayBridgeUrl(typeof parsed.url === 'string' ? parsed.url : ''),
token: typeof parsed.token === 'string' ? parsed.token : '',
channelId: typeof parsed.channelId === 'string' ? parsed.channelId : '',
deviceId: typeof parsed.deviceId === 'string' ? parsed.deviceId : '',
groupId: typeof parsed.groupId === 'string' ? parsed.groupId : '',
sourceId: typeof parsed.sourceId === 'string' ? parsed.sourceId : '',
sourceMode: typeof parsed.sourceMode === 'string' ? parsed.sourceMode : 'mock',
}
} catch (_error) {
return null
}
}
function loadBridgePresets() {
try {
const raw = window.localStorage.getItem(BRIDGE_PRESETS_STORAGE_KEY)
if (!raw) {
return []
}
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) {
return []
}
return parsed
.map((item) => {
const config = item && item.config ? item.config : {}
return {
name: item && typeof item.name === 'string' ? item.name.trim() : '',
config: {
enabled: Boolean(config.enabled),
url: normalizeGatewayBridgeUrl(typeof config.url === 'string' ? config.url : ''),
token: typeof config.token === 'string' ? config.token : '',
channelId: typeof config.channelId === 'string' ? config.channelId : '',
deviceId: typeof config.deviceId === 'string' ? config.deviceId : '',
groupId: typeof config.groupId === 'string' ? config.groupId : '',
sourceId: typeof config.sourceId === 'string' ? config.sourceId : '',
sourceMode: typeof config.sourceMode === 'string' ? config.sourceMode : 'mock',
},
}
})
.filter((item) => item.name)
} catch (_error) {
return []
}
}
function saveBridgePresets() {
try {
window.localStorage.setItem(BRIDGE_PRESETS_STORAGE_KEY, JSON.stringify(state.bridgePresets))
} catch (_error) {
// noop
}
}
function renderBridgePresetOptions(selectedName) {
const currentValue = typeof selectedName === 'string'
? selectedName
: elements.gatewayBridgePresetSelect.value
elements.gatewayBridgePresetSelect.innerHTML = '<option value="">选择预设</option>'
state.bridgePresets.forEach((preset) => {
const option = document.createElement('option')
option.value = preset.name
option.textContent = preset.name
if (preset.name === currentValue) {
option.selected = true
}
elements.gatewayBridgePresetSelect.appendChild(option)
})
}
function saveBridgeConfigDraft(config) {
try {
window.localStorage.setItem(BRIDGE_CONFIG_STORAGE_KEY, JSON.stringify({
...config,
url: normalizeGatewayBridgeUrl(config && config.url),
}))
} catch (_error) {
// noop
}
}
function fillBridgeConfigForm(config) {
elements.gatewayBridgeEnabledInput.checked = Boolean(config.enabled)
elements.gatewayBridgeUrlInput.value = normalizeGatewayBridgeUrl(config && config.url)
elements.gatewayBridgeTokenInput.value = config.token || ''
elements.gatewayBridgeChannelIdInput.value = config.channelId || ''
elements.gatewayBridgeDeviceIdInput.value = config.deviceId || ''
elements.gatewayBridgeGroupIdInput.value = config.groupId || ''
elements.gatewayBridgeSourceIdInput.value = config.sourceId || ''
elements.gatewayBridgeSourceModeInput.value = config.sourceMode || 'mock'
}
function readBridgeConfigForm() {
return {
enabled: elements.gatewayBridgeEnabledInput.checked,
url: normalizeGatewayBridgeUrl(elements.gatewayBridgeUrlInput.value),
token: String(elements.gatewayBridgeTokenInput.value || '').trim(),
channelId: String(elements.gatewayBridgeChannelIdInput.value || '').trim(),
deviceId: String(elements.gatewayBridgeDeviceIdInput.value || '').trim(),
groupId: String(elements.gatewayBridgeGroupIdInput.value || '').trim(),
sourceId: String(elements.gatewayBridgeSourceIdInput.value || '').trim(),
sourceMode: String(elements.gatewayBridgeSourceModeInput.value || '').trim() || 'mock',
}
}
function selectedBridgePreset() {
const name = String(elements.gatewayBridgePresetSelect.value || '').trim()
if (!name) {
return null
}
return state.bridgePresets.find((item) => item.name === name) || null
}
function applyBridgePresetToForm() {
const preset = selectedBridgePreset()
if (!preset) {
log('未选择桥接预设')
return
}
fillBridgeConfigForm(preset.config)
elements.gatewayBridgePresetNameInput.value = preset.name
saveBridgeConfigDraft(preset.config)
updateUiState()
log(`已载入桥接预设: ${preset.name}`)
}
function saveCurrentBridgePreset() {
const name = String(elements.gatewayBridgePresetNameInput.value || '').trim()
if (!name) {
log('请先输入预设名称')
return
}
const config = readBridgeConfigForm()
const nextPreset = { name, config }
const existingIndex = state.bridgePresets.findIndex((item) => item.name === name)
if (existingIndex >= 0) {
state.bridgePresets.splice(existingIndex, 1, nextPreset)
} else {
state.bridgePresets.push(nextPreset)
state.bridgePresets.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
}
saveBridgePresets()
renderBridgePresetOptions(name)
log(`已保存桥接预设: ${name}`)
updateUiState()
}
function deleteSelectedBridgePreset() {
const preset = selectedBridgePreset()
if (!preset) {
log('未选择桥接预设')
return
}
state.bridgePresets = state.bridgePresets.filter((item) => item.name !== preset.name)
saveBridgePresets()
renderBridgePresetOptions('')
if (elements.gatewayBridgePresetNameInput.value.trim() === preset.name) {
elements.gatewayBridgePresetNameInput.value = ''
}
log(`已删除桥接预设: ${preset.name}`)
updateUiState()
}
function connectSocket() {
if (state.socket && (state.socket.readyState === WebSocket.OPEN || state.socket.readyState === WebSocket.CONNECTING)) {
return
}
const socket = new WebSocket(GPS_WS_URL)
state.socket = socket
state.socketConnecting = true
setSocketBadge(false)
updateUiState()
log(`连接 ${GPS_WS_URL}`)
socket.addEventListener('open', () => {
state.connected = true
state.socketConnecting = false
setSocketBadge(true)
updateUiState()
log('桥接已连接')
})
socket.addEventListener('close', () => {
state.connected = false
state.socketConnecting = false
stopStream()
setSocketBadge(false)
updateUiState()
log('桥接已断开')
})
socket.addEventListener('error', () => {
state.connected = false
state.socketConnecting = false
stopStream()
setSocketBadge(false)
updateUiState()
log('桥接连接失败')
})
}
function connectHeartRateSocket() {
if (state.heartRateSocket && (state.heartRateSocket.readyState === WebSocket.OPEN || state.heartRateSocket.readyState === WebSocket.CONNECTING)) {
return
}
const socket = new WebSocket(HEART_RATE_WS_URL)
state.heartRateSocket = socket
state.heartRateSocketConnecting = true
updateUiState()
log(`连接心率模拟 ${HEART_RATE_WS_URL}`)
socket.addEventListener('open', () => {
state.heartRateConnected = true
state.heartRateSocketConnecting = false
updateUiState()
log('心率模拟已连接')
})
socket.addEventListener('close', () => {
state.heartRateConnected = false
state.heartRateSocketConnecting = false
state.heartRateSocket = null
stopHeartRateStream()
updateUiState()
log('心率模拟已断开')
})
socket.addEventListener('error', () => {
state.heartRateConnected = false
state.heartRateSocketConnecting = false
state.heartRateSocket = null
stopHeartRateStream()
updateUiState()
log('心率模拟连接失败')
})
}
function connectDebugSocket() {
if (state.debugSocket && (state.debugSocket.readyState === WebSocket.OPEN || state.debugSocket.readyState === WebSocket.CONNECTING)) {
return
}
const socket = new WebSocket(DEBUG_LOG_WS_URL)
state.debugSocket = socket
state.debugConnected = false
state.debugSocketConnecting = true
log(`连接日志通道 ${DEBUG_LOG_WS_URL}`)
socket.addEventListener('message', (event) => {
let parsed = null
try {
parsed = JSON.parse(String(event.data || ''))
} catch (_error) {
return
}
if (parsed && parsed.type === 'debug-log') {
logDebug(parsed)
}
})
socket.addEventListener('open', () => {
state.debugSocketConnecting = false
state.debugSocket = socket
state.debugConnected = true
log('日志通道已连接')
updateUiState()
})
socket.addEventListener('close', () => {
state.debugSocketConnecting = false
state.debugSocket = null
state.debugConnected = false
log('日志通道已断开')
updateUiState()
window.setTimeout(connectDebugSocket, 1500)
})
socket.addEventListener('error', () => {
state.debugSocketConnecting = false
state.debugSocket = null
state.debugConnected = false
log('日志通道连接失败')
updateUiState()
})
}
async function refreshGatewayBridgeStatus() {
try {
const response = await fetch('/bridge-status', {
cache: 'no-store',
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const status = await response.json()
state.bridgeEnabled = Boolean(status.enabled)
state.bridgeConnected = Boolean(status.connected)
state.bridgeAuthenticated = Boolean(status.authenticated)
if (status.channelId) {
state.bridgeTargetText = `${status.channelId}${status.deviceId ? ` / ${status.deviceId}` : ''}${status.groupId ? ` / ${status.groupId}` : ''}`
} else {
state.bridgeTargetText = status.deviceId
? `${status.deviceId}${status.groupId ? ` / ${status.groupId}` : ''}`
: '--'
}
state.bridgeLastStatusText = status.lastError
? `错误: ${status.lastError}`
: status.lastSentAt
? `${status.lastSentTopic || 'unknown'} @ ${formatClockTime(status.lastSentAt)}`
: '待命'
updateUiState()
} catch (_error) {
state.bridgeEnabled = false
state.bridgeConnected = false
state.bridgeAuthenticated = false
state.bridgeTargetText = '--'
state.bridgeLastStatusText = '状态读取失败'
updateUiState()
}
}
async function loadGatewayBridgeConfig(options) {
const preserveForm = Boolean(options && options.preserveForm)
const response = await fetch('/bridge-config', {
cache: 'no-store',
})
if (!response.ok) {
throw new Error(`桥接配置读取失败: HTTP ${response.status}`)
}
const payload = await response.json()
if (!preserveForm) {
fillBridgeConfigForm(bridgeConfigFromServerPayload(payload))
}
return payload
}
async function applyGatewayBridgeConfig() {
const config = readBridgeConfigForm()
if (!config.url) {
log('桥接配置缺少网关地址')
return
}
if (!config.deviceId) {
log('桥接配置缺少目标 Device ID')
return
}
if (!config.sourceId) {
log('桥接配置缺少 Source ID')
return
}
state.bridgeConfigSaving = true
updateUiState()
try {
const response = await fetch('/bridge-config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
})
const payload = await response.json()
if (!response.ok) {
throw new Error(payload && payload.error ? payload.error : `HTTP ${response.status}`)
}
saveBridgeConfigDraft(config)
fillBridgeConfigForm(bridgeConfigFromServerPayload(payload))
await refreshGatewayBridgeStatus()
log(`已应用新网关桥接配置 -> ${config.deviceId}`)
} catch (error) {
log(error && error.message ? error.message : '桥接配置应用失败')
} finally {
state.bridgeConfigSaving = false
updateUiState()
}
}
function proxyUrl(targetUrl) {
return `${PROXY_BASE_URL}${encodeURIComponent(targetUrl)}`
}
async function fetchJson(targetUrl) {
const response = await fetch(proxyUrl(targetUrl), { cache: 'no-store' })
if (!response.ok) {
throw new Error(`载入失败: ${response.status} ${targetUrl}`)
}
const text = await response.text()
return parseJsonWithFallback(text)
}
async function fetchText(targetUrl) {
const response = await fetch(proxyUrl(targetUrl), { cache: 'no-store' })
if (!response.ok) {
throw new Error(`载入失败: ${response.status} ${targetUrl}`)
}
return response.text()
}
function parseJsonWithFallback(text) {
try {
return JSON.parse(text)
} catch (_error) {
const sanitized = text
.replace(/,\s*"center"\s*:\s*\[[^\]]*\]\s*(?=[}\r\n])/g, '')
.replace(/"center"\s*:\s*\[[^\]]*\]\s*,/g, '')
.replace(/,\s*([}\]])/g, '$1')
return JSON.parse(sanitized)
}
}
function resolveUrl(baseUrl, relativePath) {
const trimmed = String(relativePath || '').trim()
if (!trimmed) {
return ''
}
if (/^https?:\/\//i.test(trimmed)) {
return trimmed
}
const url = new URL(baseUrl)
if (trimmed.startsWith('/')) {
return `${url.origin}${trimmed}`
}
const baseDir = baseUrl.slice(0, baseUrl.lastIndexOf('/') + 1)
return `${baseDir}${trimmed.replace(/^\.\//, '')}`
}
function joinUrl(rootUrl, relativePath) {
const normalizedRoot = String(rootUrl || '').replace(/\/+$/, '')
const normalizedPath = String(relativePath || '').replace(/^\/+/, '')
return `${normalizedRoot}/${normalizedPath}`
}
function webMercatorToLatLng(x, y) {
const lon = x / 20037508.34 * 180
let lat = y / 20037508.34 * 180
lat = 180 / Math.PI * (2 * Math.atan(Math.exp(lat * Math.PI / 180)) - Math.PI / 2)
return L.latLng(lat, lon)
}
function applyTileTemplate(tileUrl, options) {
const trimmed = String(tileUrl || '').trim()
if (!trimmed) {
throw new Error('瓦片模板不能为空')
}
if (tileLayer) {
map.removeLayer(tileLayer)
}
tileLayer = createTileLayer(trimmed, options || {}).addTo(map)
elements.tileUrlInput.value = trimmed
}
function fitBoundsFromMercator(bounds) {
if (!Array.isArray(bounds) || bounds.length !== 4) {
return
}
const southWest = webMercatorToLatLng(Number(bounds[0]), Number(bounds[1]))
const northEast = webMercatorToLatLng(Number(bounds[2]), Number(bounds[3]))
map.fitBounds(L.latLngBounds(southWest, northEast), { padding: [24, 24] })
}
function parseCoordinateTuple(rawValue) {
const parts = rawValue.trim().split(',')
if (parts.length < 2) {
return null
}
const lon = Number(parts[0])
const lat = Number(parts[1])
if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
return null
}
return { lat, lon }
}
function extractPointCoordinates(block) {
const pointMatch = block.match(/<Point\b[\s\S]*?<coordinates>([\s\S]*?)<\/coordinates>[\s\S]*?<\/Point>/i)
if (!pointMatch) {
return null
}
const coordinateMatch = pointMatch[1].trim().match(/-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?(?:,-?\d+(?:\.\d+)?)?/)
return coordinateMatch ? parseCoordinateTuple(coordinateMatch[0]) : null
}
function decodeXmlEntities(text) {
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&')
}
function stripXml(text) {
return decodeXmlEntities(String(text || '').replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim()
}
function extractTagText(block, tagName) {
const match = block.match(new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i'))
return match ? stripXml(match[1]) : ''
}
function normalizeCourseLabel(label) {
return String(label || '').trim().replace(/\s+/g, ' ')
}
function inferExplicitKind(label, placemarkBlock) {
const normalized = normalizeCourseLabel(label).toUpperCase().replace(/[^A-Z0-9]/g, '')
const styleHint = String(placemarkBlock || '').toUpperCase()
if (
normalized === 'S'
|| normalized.startsWith('START')
|| /^S\d+$/.test(normalized)
|| styleHint.includes('START')
|| styleHint.includes('TRIANGLE')
) {
return 'start'
}
if (
normalized === 'F'
|| normalized === 'M'
|| normalized.startsWith('FINISH')
|| normalized.startsWith('GOAL')
|| /^F\d+$/.test(normalized)
|| styleHint.includes('FINISH')
|| styleHint.includes('GOAL')
) {
return 'finish'
}
return null
}
function extractPlacemarkPoints(kmlText) {
const placemarkBlocks = kmlText.match(/<Placemark\b[\s\S]*?<\/Placemark>/gi) || []
const points = []
placemarkBlocks.forEach((placemarkBlock) => {
const point = extractPointCoordinates(placemarkBlock)
if (!point) {
return
}
const label = normalizeCourseLabel(extractTagText(placemarkBlock, 'name'))
points.push({
label,
point,
explicitKind: inferExplicitKind(label, placemarkBlock),
})
})
return points
}
function classifyOrderedNodes(points) {
if (!points.length) {
return []
}
const startIndex = points.findIndex((point) => point.explicitKind === 'start')
let finishIndex = -1
for (let index = points.length - 1; index >= 0; index -= 1) {
if (points[index].explicitKind === 'finish') {
finishIndex = index
break
}
}
return points.map((point, index) => {
let kind = point.explicitKind
if (!kind) {
if (startIndex === -1 && index === 0) {
kind = 'start'
} else if (finishIndex === -1 && points.length > 1 && index === points.length - 1) {
kind = 'finish'
} else {
kind = 'control'
}
}
return {
label: point.label,
point: point.point,
kind,
}
})
}
function parseCourseKml(kmlText) {
const points = extractPlacemarkPoints(kmlText)
if (!points.length) {
throw new Error('KML 中没有可用的 Point 控制点')
}
const nodes = classifyOrderedNodes(points)
const starts = []
const controls = []
const finishes = []
let controlSequence = 1
nodes.forEach((node) => {
if (node.kind === 'start') {
starts.push({
label: node.label || 'Start',
point: node.point,
})
return
}
if (node.kind === 'finish') {
finishes.push({
label: node.label || 'Finish',
point: node.point,
})
return
}
controls.push({
label: node.label || String(controlSequence),
sequence: controlSequence,
point: node.point,
})
controlSequence += 1
})
return {
title: extractTagText(kmlText, 'name') || 'Orienteering Course',
starts,
controls,
finishes,
}
}
function buildDivIcon(className, html, size) {
return L.divIcon({
className,
html,
iconSize: size,
iconAnchor: [size[0] / 2, size[1] / 2],
})
}
function setCurrentPosition(lat, lon) {
state.currentLatLng = L.latLng(lat, lon)
updateReadout()
}
function jumpToPoint(lat, lon, zoom) {
setCurrentPosition(lat, lon)
map.flyTo([lat, lon], zoom || Math.max(map.getZoom(), 18), {
duration: 0.6,
})
}
function buildJumpChip(label, point, className) {
const button = document.createElement('button')
button.type = 'button'
button.className = `jump-chip ${className || ''}`.trim()
button.textContent = label
button.addEventListener('click', () => {
jumpToPoint(point.lat, point.lon, 19)
log(`跳转到 ${label}`)
})
return button
}
function refreshCourseJumpList(course) {
elements.courseJumpList.innerHTML = ''
if (!course) {
return
}
course.starts.forEach((item) => {
elements.courseJumpList.appendChild(buildJumpChip('开始点', item.point, 'jump-chip--start'))
})
course.controls.forEach((item) => {
elements.courseJumpList.appendChild(buildJumpChip(String(item.sequence), item.point, ''))
})
course.finishes.forEach((item) => {
elements.courseJumpList.appendChild(buildJumpChip('结束点', item.point, 'jump-chip--finish'))
})
}
function renderCourse(course) {
courseLayer.clearLayers()
state.loadedCourse = course
refreshCourseJumpList(course)
course.starts.forEach((item) => {
const marker = L.marker([item.point.lat, item.point.lon], {
icon: buildDivIcon('course-marker', '<div class="course-marker__start"></div>', [36, 36]),
})
marker.on('click', () => jumpToPoint(item.point.lat, item.point.lon, 19))
marker.addTo(courseLayer)
})
course.controls.forEach((item) => {
const marker = L.marker([item.point.lat, item.point.lon], {
icon: buildDivIcon(
'course-marker',
`<div class="course-marker__control">${item.sequence}</div>`,
[40, 40],
),
})
marker.on('click', () => jumpToPoint(item.point.lat, item.point.lon, 19))
marker.addTo(courseLayer)
})
course.finishes.forEach((item) => {
const marker = L.marker([item.point.lat, item.point.lon], {
icon: buildDivIcon('course-marker', '<div class="course-marker__finish"></div>', [40, 40]),
})
marker.on('click', () => jumpToPoint(item.point.lat, item.point.lon, 19))
marker.addTo(courseLayer)
})
fitCourseBounds()
updateUiState()
}
function clearCourse() {
state.loadedCourse = null
courseLayer.clearLayers()
refreshCourseJumpList(null)
setResourceStatus('已清空控制点', 'warn')
state.lastResourceDetailText = '已清空控制点'
updateUiState()
}
function fitCourseBounds() {
if (!state.loadedCourse) {
return
}
const latLngs = []
state.loadedCourse.starts.forEach((item) => latLngs.push([item.point.lat, item.point.lon]))
state.loadedCourse.controls.forEach((item) => latLngs.push([item.point.lat, item.point.lon]))
state.loadedCourse.finishes.forEach((item) => latLngs.push([item.point.lat, item.point.lon]))
if (!latLngs.length) {
return
}
map.fitBounds(L.latLngBounds(latLngs), { padding: [30, 30] })
}
async function loadCourseFromUrl(courseUrl, shouldFit) {
const trimmed = String(courseUrl || '').trim()
if (!trimmed) {
throw new Error('KML 地址不能为空')
}
const kmlText = await fetchText(trimmed)
const course = parseCourseKml(kmlText)
renderCourse(course)
elements.courseUrlInput.value = trimmed
if (shouldFit !== false) {
fitCourseBounds()
}
setResourceStatus(`已载入控制点: ${course.title}`, 'ok')
state.lastResourceDetailText = `最近资源: 控制点 ${course.title} (${formatClockTime(Date.now())})`
log(`已载入 KML: ${trimmed}`)
updateUiState()
}
async function loadConfigResources() {
const configUrl = String(elements.configUrlInput.value || '').trim()
if (!configUrl) {
setResourceStatus('请先填写 game.json 地址', 'warn')
return
}
state.resourceLoading = true
updateUiState()
setResourceStatus('正在载入配置...', null)
try {
const config = await fetchJson(configUrl)
let mapStatus = '未找到瓦片配置'
if (config.map && config.mapmeta) {
const mapRootUrl = resolveUrl(configUrl, config.map)
const mapMetaUrl = resolveUrl(configUrl, config.mapmeta)
const mapMeta = await fetchJson(mapMetaUrl)
const tilePathTemplate = mapMeta.tilePathTemplate || `{z}/{x}/{y}.${mapMeta.tileFormat || 'png'}`
const tileTemplateUrl = /^https?:\/\//i.test(tilePathTemplate)
? tilePathTemplate
: joinUrl(mapRootUrl, tilePathTemplate)
applyTileTemplate(tileTemplateUrl, {
minZoom: Number.isFinite(mapMeta.minZoom) ? mapMeta.minZoom : 16,
maxZoom: Number.isFinite(mapMeta.maxZoom) ? mapMeta.maxZoom : 20,
attribution: 'Custom Map',
})
mapStatus = '已载入瓦片'
if (Array.isArray(mapMeta.bounds) && mapMeta.bounds.length === 4) {
fitBoundsFromMercator(mapMeta.bounds)
}
}
let courseStatus = '未找到 KML 配置'
if (config.course) {
const courseUrl = resolveUrl(configUrl, config.course)
elements.courseUrlInput.value = courseUrl
await loadCourseFromUrl(courseUrl, false)
courseStatus = '已载入控制点'
}
setResourceStatus(`配置已载入: ${mapStatus} / ${courseStatus}`, 'ok')
state.lastResourceDetailText = `最近资源: 配置 ${formatClockTime(Date.now())}`
log(`已载入配置: ${configUrl}`)
} catch (error) {
const message = error && error.message ? error.message : '未知错误'
setResourceStatus(`配置载入失败: ${message}`, 'warn')
log(`配置载入失败: ${message}`)
} finally {
state.resourceLoading = false
updateUiState()
}
}
function getAccuracy() {
return Math.max(1, Number(elements.accuracyInput.value) || 6)
}
function getSpeedMps() {
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('未连接桥接,无法发送')
return
}
const payload = {
type: 'mock_gps',
timestamp: Date.now(),
lat: Number(state.currentLatLng.lat.toFixed(6)),
lon: Number(state.currentLatLng.lng.toFixed(6)),
accuracyMeters: getAccuracy(),
speedMps: Number(getSpeedMps().toFixed(2)),
headingDeg: Number(state.headingDeg.toFixed(1)),
}
state.socket.send(JSON.stringify(payload))
state.lastSentText = `${formatClockTime(payload.timestamp)} @ ${payload.lat.toFixed(6)}, ${payload.lon.toFixed(6)}`
updateUiState()
}
function sendCurrentHeartRate() {
if (!state.heartRateSocket || state.heartRateSocket.readyState !== WebSocket.OPEN) {
log('未连接心率模拟,无法发送心率')
return
}
const payload = {
type: 'mock_heart_rate',
timestamp: Date.now(),
bpm: state.heartRateSampleMode ? getSampleHeartRateBpm() : getHeartRateBpm(),
}
state.heartRateSocket.send(JSON.stringify(payload))
state.lastHeartRateSentText = `${formatClockTime(payload.timestamp)} @ ${payload.bpm} bpm`
updateUiState()
}
function startStream() {
stopStream()
state.streaming = true
const intervalMs = Math.max(80, 1000 / (Number(elements.hzSelect.value) || 5))
sendCurrentPoint()
state.streamTimer = window.setInterval(sendCurrentPoint, intervalMs)
updateUiState()
log(`开始连续发送 (${Math.round(1000 / intervalMs)} Hz)`)
}
function stopStream() {
state.streaming = false
if (state.streamTimer) {
window.clearInterval(state.streamTimer)
state.streamTimer = 0
log('已停止连续发送')
}
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)
updateUiState()
}
function clearPathMarkers() {
while (pathMarkers.length) {
map.removeLayer(pathMarkers.pop())
}
}
function refreshPathMarkers() {
clearPathMarkers()
pathPoints.forEach((point, index) => {
const marker = L.circleMarker(point, {
radius: 5,
color: '#ffffff',
weight: 2,
fillColor: index === 0 ? '#0ea5a4' : '#0b625b',
fillOpacity: 0.95,
}).addTo(map)
pathMarkers.push(marker)
})
}
function addPathPoint(latlng) {
pathPoints.push(L.latLng(latlng.lat, latlng.lng))
state.lastTrackSourceText = '手工路径'
syncPathLine()
refreshPathMarkers()
}
function fitPathBounds() {
if (pathPoints.length < 2) {
return
}
map.fitBounds(L.latLngBounds(pathPoints), { padding: [30, 30] })
}
function replacePathPoints(nextPoints, sourceLabel) {
pathPoints.splice(0, pathPoints.length)
nextPoints.forEach((point) => {
pathPoints.push(L.latLng(point.lat, point.lng))
})
state.lastTrackSourceText = sourceLabel
stopPlayback()
syncPathLine()
refreshPathMarkers()
if (pathPoints.length) {
state.currentLatLng = L.latLng(pathPoints[0].lat, pathPoints[0].lng)
updateReadout()
}
if (pathPoints.length >= 2) {
fitPathBounds()
}
}
function parseGeoJsonTrack(rawValue) {
const latLngs = []
function pushLngLat(coords) {
if (!Array.isArray(coords) || coords.length < 2) {
return
}
const lng = Number(coords[0])
const lat = Number(coords[1])
if (Number.isFinite(lat) && Number.isFinite(lng)) {
latLngs.push({ lat, lng })
}
}
function walk(node) {
if (!node || typeof node !== 'object') {
return
}
if (node.type === 'FeatureCollection' && Array.isArray(node.features)) {
node.features.forEach(walk)
return
}
if (node.type === 'Feature' && node.geometry) {
walk(node.geometry)
return
}
if (node.type === 'LineString' && Array.isArray(node.coordinates)) {
node.coordinates.forEach(pushLngLat)
return
}
if (node.type === 'MultiLineString' && Array.isArray(node.coordinates)) {
node.coordinates.forEach((line) => {
if (Array.isArray(line)) {
line.forEach(pushLngLat)
}
})
}
}
if (Array.isArray(rawValue)) {
rawValue.forEach((item) => {
if (Array.isArray(item)) {
pushLngLat(item)
return
}
if (item && typeof item === 'object') {
const lat = Number(item.lat)
const lng = Number(item.lng !== undefined ? item.lng : item.lon)
if (Number.isFinite(lat) && Number.isFinite(lng)) {
latLngs.push({ lat, lng })
}
}
})
return latLngs
}
walk(rawValue)
return latLngs
}
function parseGpxTrack(text) {
const xml = new DOMParser().parseFromString(text, 'application/xml')
const latLngs = []
const trackPoints = Array.from(xml.querySelectorAll('trkpt'))
const routePoints = trackPoints.length ? [] : Array.from(xml.querySelectorAll('rtept'))
const nodes = trackPoints.length ? trackPoints : routePoints
nodes.forEach((node) => {
const lat = Number(node.getAttribute('lat'))
const lng = Number(node.getAttribute('lon'))
if (Number.isFinite(lat) && Number.isFinite(lng)) {
latLngs.push({ lat, lng })
}
})
return latLngs
}
function parseKmlTrack(text) {
const xml = new DOMParser().parseFromString(text, 'application/xml')
const latLngs = []
const lineStrings = Array.from(xml.querySelectorAll('LineString coordinates'))
lineStrings.forEach((node) => {
String(node.textContent || '')
.trim()
.split(/\s+/)
.forEach((tuple) => {
const parsed = parseCoordinateTuple(tuple)
if (parsed) {
latLngs.push({ lat: parsed.lat, lng: parsed.lon })
}
})
})
return latLngs
}
function parseTrackFile(fileName, text) {
const lowerName = String(fileName || '').toLowerCase()
if (lowerName.endsWith('.gpx')) {
return parseGpxTrack(text)
}
if (lowerName.endsWith('.kml')) {
return parseKmlTrack(text)
}
if (lowerName.endsWith('.geojson') || lowerName.endsWith('.json')) {
return parseGeoJsonTrack(parseJsonWithFallback(text))
}
if (text.includes('<gpx')) {
return parseGpxTrack(text)
}
if (text.includes('<kml') || text.includes('<LineString')) {
return parseKmlTrack(text)
}
return parseGeoJsonTrack(parseJsonWithFallback(text))
}
async function handleTrackFileSelected(file) {
if (!file) {
return
}
try {
const text = await file.text()
const latLngs = parseTrackFile(file.name, text)
if (!latLngs || latLngs.length < 2) {
throw new Error('轨迹文件中没有可回放的路径点')
}
replacePathPoints(latLngs, `轨迹文件 ${file.name}`)
log(`已导入轨迹文件: ${file.name} (${latLngs.length} 点)`)
} catch (error) {
const message = error && error.message ? error.message : '轨迹文件导入失败'
log(message)
alert(message)
} finally {
elements.trackFileInput.value = ''
}
}
function toRad(value) {
return value * Math.PI / 180
}
function toDeg(value) {
return value * 180 / Math.PI
}
function getDistanceMeters(from, to) {
const earth = 6371000
const lat1 = toRad(from.lat)
const lat2 = toRad(to.lat)
const dLat = lat2 - lat1
const dLon = toRad(to.lng - from.lng)
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) * Math.sin(dLon / 2)
return earth * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
function getHeadingDeg(from, to) {
const lat1 = toRad(from.lat)
const lat2 = toRad(to.lat)
const dLon = toRad(to.lng - from.lng)
const y = Math.sin(dLon) * Math.cos(lat2)
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon)
const bearing = (toDeg(Math.atan2(y, x)) + 360) % 360
return bearing
}
function interpolateLatLng(from, to, t) {
return L.latLng(
from.lat + (to.lat - from.lat) * t,
from.lng + (to.lng - from.lng) * t,
)
}
function tickPlayback() {
if (!state.playbackRunning || pathPoints.length < 2) {
return
}
const now = performance.now()
if (!state.lastPlaybackAt) {
state.lastPlaybackAt = now
}
const deltaSeconds = (now - state.lastPlaybackAt) / 1000
state.lastPlaybackAt = now
let remainingTravel = getSpeedMps() * deltaSeconds
while (remainingTravel > 0 && state.currentSegmentIndex < pathPoints.length - 1) {
const from = pathPoints[state.currentSegmentIndex]
const to = pathPoints[state.currentSegmentIndex + 1]
const segmentDistance = getDistanceMeters(from, to)
if (!segmentDistance) {
state.currentSegmentIndex += 1
state.currentSegmentProgress = 0
continue
}
const remainingSegment = segmentDistance * (1 - state.currentSegmentProgress)
if (remainingTravel >= remainingSegment) {
remainingTravel -= remainingSegment
state.currentSegmentIndex += 1
state.currentSegmentProgress = 0
state.currentLatLng = L.latLng(to.lat, to.lng)
state.headingDeg = getHeadingDeg(from, to)
} else {
state.currentSegmentProgress += remainingTravel / segmentDistance
state.currentLatLng = interpolateLatLng(from, to, state.currentSegmentProgress)
state.headingDeg = getHeadingDeg(from, to)
remainingTravel = 0
}
}
if (state.currentSegmentIndex >= pathPoints.length - 1) {
if (elements.loopPathInput.checked) {
state.currentSegmentIndex = 0
state.currentSegmentProgress = 0
state.currentLatLng = L.latLng(pathPoints[0].lat, pathPoints[0].lng)
} else {
stopPlayback()
}
}
updateReadout()
if (state.streaming) {
sendCurrentPoint()
}
if (state.playbackRunning) {
state.playbackTimer = window.requestAnimationFrame(tickPlayback)
}
}
function startPlayback() {
if (pathPoints.length < 2) {
log('至少需要两个路径点')
return
}
stopPlayback()
state.playbackRunning = true
state.currentSegmentIndex = 0
state.currentSegmentProgress = 0
state.currentLatLng = L.latLng(pathPoints[0].lat, pathPoints[0].lng)
state.lastPlaybackAt = 0
updateReadout()
updateUiState()
log('开始路径回放')
state.playbackTimer = window.requestAnimationFrame(tickPlayback)
}
function stopPlayback() {
state.playbackRunning = false
state.lastPlaybackAt = 0
if (state.playbackTimer) {
window.cancelAnimationFrame(state.playbackTimer)
state.playbackTimer = 0
}
updateUiState()
}
map.on('click', (event) => {
if (state.pathEditMode) {
addPathPoint(event.latlng)
return
}
setCurrentPosition(event.latlng.lat, event.latlng.lng)
})
liveMarker.on('mousedown', () => {
map.dragging.disable()
})
map.on('mousemove', (event) => {
if (event.originalEvent.buttons !== 1) {
return
}
if (state.pathEditMode) {
return
}
setCurrentPosition(event.latlng.lat, event.latlng.lng)
})
map.on('mouseup', () => {
map.dragging.enable()
})
elements.connectBtn.addEventListener('click', connectSocket)
elements.applyGatewayBridgePresetBtn.addEventListener('click', applyBridgePresetToForm)
elements.saveGatewayBridgePresetBtn.addEventListener('click', saveCurrentBridgePreset)
elements.deleteGatewayBridgePresetBtn.addEventListener('click', deleteSelectedBridgePreset)
elements.gatewayBridgePresetSelect.addEventListener('change', () => {
const preset = selectedBridgePreset()
elements.gatewayBridgePresetNameInput.value = preset ? preset.name : ''
updateUiState()
})
elements.applyGatewayBridgeConfigBtn.addEventListener('click', applyGatewayBridgeConfig)
elements.reloadGatewayBridgeConfigBtn.addEventListener('click', async () => {
try {
await loadGatewayBridgeConfig()
await refreshGatewayBridgeStatus()
log('已重新读取桥接配置')
} catch (error) {
log(error && error.message ? error.message : '桥接配置读取失败')
}
})
elements.importTrackBtn.addEventListener('click', () => {
elements.trackFileInput.click()
})
elements.trackFileInput.addEventListener('change', (event) => {
const input = event.target
const file = input && input.files && input.files[0] ? input.files[0] : null
handleTrackFileSelected(file)
})
elements.loadConfigBtn.addEventListener('click', loadConfigResources)
elements.fitCourseBtn.addEventListener('click', fitCourseBounds)
elements.applyTilesBtn.addEventListener('click', () => {
try {
applyTileTemplate(elements.tileUrlInput.value, { attribution: 'Custom Map' })
setResourceStatus('已应用自定义瓦片', 'ok')
state.lastResourceDetailText = `最近资源: 自定义瓦片 ${formatClockTime(Date.now())}`
updateUiState()
} catch (error) {
setResourceStatus(error && error.message ? error.message : '瓦片应用失败', 'warn')
}
})
elements.resetTilesBtn.addEventListener('click', () => {
applyTileTemplate(DEFAULT_TILE_URL, {
maxZoom: 20,
attribution: '&copy; OpenStreetMap',
})
setResourceStatus('已恢复 OSM 底图', 'ok')
state.lastResourceDetailText = `最近资源: OSM 底图 ${formatClockTime(Date.now())}`
updateUiState()
})
elements.loadCourseBtn.addEventListener('click', async () => {
try {
await loadCourseFromUrl(elements.courseUrlInput.value, true)
} catch (error) {
const message = error && error.message ? error.message : 'KML 载入失败'
setResourceStatus(message, 'warn')
log(message)
}
})
elements.clearCourseBtn.addEventListener('click', clearCourse)
elements.fitPathBtn.addEventListener('click', fitPathBounds)
elements.sendOnceBtn.addEventListener('click', () => {
sendCurrentPoint()
log('已发送当前位置')
})
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
? '地图点击将按顺序追加路径点。'
: '点击“开启路径编辑”后,在地图上逐点添加路径。'
updateUiState()
})
elements.clearPathBtn.addEventListener('click', () => {
pathPoints.splice(0, pathPoints.length)
state.lastTrackSourceText = '路径待命'
syncPathLine()
clearPathMarkers()
stopPlayback()
log('已清空路径')
})
elements.playPathBtn.addEventListener('click', startPlayback)
elements.pausePathBtn.addEventListener('click', () => {
stopPlayback()
log('已暂停回放')
})
if (elements.clearDebugLogBtn) {
elements.clearDebugLogBtn.addEventListener('click', clearDebugLog)
}
if (elements.toggleDebugLogPanelBtn) {
elements.toggleDebugLogPanelBtn.addEventListener('click', () => {
state.debugLogPanelMinimized = !state.debugLogPanelMinimized
updateDebugLogPanelState()
})
}
if (elements.debugLogScopeFilter) {
elements.debugLogScopeFilter.addEventListener('change', () => {
state.debugLogScopeFilter = elements.debugLogScopeFilter.value || 'all'
renderDebugLog()
})
}
updateReadout()
setSocketBadge(false)
renderDebugScopeOptions()
renderDebugLog()
updateDebugLogPanelState()
setResourceStatus('支持直接载入 game.json也支持单独填瓦片模板和 KML 地址。', null)
state.bridgePresets = loadBridgePresets()
renderBridgePresetOptions('')
updateUiState()
const draftBridgeConfig = getBridgeConfigDraft()
if (draftBridgeConfig) {
fillBridgeConfigForm(draftBridgeConfig)
}
loadGatewayBridgeConfig({ preserveForm: Boolean(draftBridgeConfig) })
.then(async () => {
if (draftBridgeConfig) {
log('已恢复上次桥接配置草稿,可直接点“应用桥接配置”')
}
await refreshGatewayBridgeStatus()
})
.catch((error) => {
log(error && error.message ? error.message : '桥接配置读取失败')
refreshGatewayBridgeStatus()
})
refreshGatewayBridgeStatus()
window.setInterval(refreshGatewayBridgeStatus, 3000)
connectSocket()
connectHeartRateSocket()
connectDebugSocket()
})()