(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 SIM_CHANNEL_STORAGE_KEY = 'mock-gps-sim.channel-id' const MAX_DEBUG_LOG_LINES = 400 function normalizeSimChannelId(rawValue) { const trimmed = String(rawValue || '').trim() return trimmed || 'default' } 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: '路径待命', simChannelId: 'default', 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'), simChannelIdInput: document.getElementById('simChannelIdInput'), 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 applySimChannelId(loadSimChannelId(), false) 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 loadSimChannelId() { try { return normalizeSimChannelId(window.localStorage.getItem(SIM_CHANNEL_STORAGE_KEY)) } catch (_error) { return 'default' } } function saveSimChannelId(channelId) { try { window.localStorage.setItem(SIM_CHANNEL_STORAGE_KEY, normalizeSimChannelId(channelId)) } catch (_error) { // noop } } function applySimChannelId(channelId, persist) { state.simChannelId = normalizeSimChannelId(channelId) if (elements.simChannelIdInput) { elements.simChannelIdInput.value = state.simChannelId } if (persist) { saveSimChannelId(state.simChannelId) } renderDebugLog() } function logDebug(entry) { if (!elements.debugLog) { return } const normalized = { timestamp: entry.timestamp || Date.now(), channelId: normalizeSimChannelId(entry.channelId), 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) => ``) .join('') elements.debugLogScopeFilter.value = currentValue state.debugLogScopeFilter = currentValue } function renderDebugLog() { if (!elements.debugLog) { return } const filteredEntries = state.debugLogEntries.filter((entry) => { if (normalizeSimChannelId(entry.channelId) !== state.simChannelId) { return false } return state.debugLogScopeFilter === 'all' || entry.scope === state.debugLogScopeFilter }) if (elements.debugLogMeta) { const scopeLabel = state.debugLogScopeFilter === 'all' ? '全部' : state.debugLogScopeFilter elements.debugLogMeta.textContent = `通道 ${state.simChannelId} · ${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.channelId}] [${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 = '' 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(/([\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(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'") .replace(/&/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(//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', '
', [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', `
${item.sequence}
`, [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', '
', [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(), channelId: state.simChannelId, 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(), channelId: state.simChannelId, 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(' 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) if (elements.simChannelIdInput) { elements.simChannelIdInput.addEventListener('change', () => { applySimChannelId(elements.simChannelIdInput.value, true) log(`已切换模拟通道 ${state.simChannelId}`) }) } 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: '© 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() })()